Repository navigation
Expand file tree
/
Copy pathcubeBuilder.ts
More file actions
465 lines (421 loc) · 15.8 KB
/
Copy pathcubeBuilder.ts
File metadata and controls
465 lines (421 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
/**
* CubeBuilder — turns an [`Observations`](../model/ir.ts) IR into a JSON-stat
* [`Dataset`](../model/jsonstat.ts).
*
* This is the central transform of the importer: a tidy long table → an
* N-dimensional cube laid out in row-major order ("what does not change, first";
* see https://jsonstat.org/format/).
*
* Responsibilities:
* 1. Resolve dimension order and enumerate categories (first-seen order unless
* `categoryOrder` pins it).
* 2. Build the `dimension` block with `category.index`/`label`/`unit`/
* `coordinates`/`child` and dimension `label`.
* 3. Scatter the measure values into a row-major `value` array (dense) or a
* sparse object, per [`CubeModel.valueForm`](../model/ir.ts) and the null
* ratio threshold.
* 4. Emit `status` in the most compact correct form (string/array/object).
* 5. Attach `role`, and dataset-level `label`/`source`/`updated`/`extension`.
*
* Pure: no I/O, no side effects. All index math delegated to
* [`strides`](./strides.ts).
*/
import type { DatasetMeta, DimensionColumn, Observations } from "../model/ir";
import type {
JsonStatCategory,
JsonStatDataset,
JsonStatDimension,
JsonStatRole,
JsonStatStatus,
JsonStatValue,
} from "../model/jsonstat";
import { enumerateCells, flatPosition, totalCells } from "./strides";
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/** Thrown when the IR violates a structural invariant the builder relies on. */
export class CubeBuilderError extends Error {
constructor(message: string) {
super(message);
this.name = "CubeBuilderError";
}
}
// ---------------------------------------------------------------------------
// Options
// ---------------------------------------------------------------------------
export interface BuildOptions {
/** Override the emitted value form. Defaults to `model.valueForm ?? "auto"`. */
valueForm?: "auto" | "dense" | "sparse";
/** Override the sparse threshold (null ratio). Defaults to 0.5. */
sparseThreshold?: number;
/** Override status emission. Defaults to `model.statusForm ?? "auto"`. */
statusForm?: "auto" | "array" | "string" | "object" | "none";
/** Override dataset metadata. Defaults to `model.meta`. */
meta?: DatasetMeta;
}
// ---------------------------------------------------------------------------
// Category resolution
// ---------------------------------------------------------------------------
/**
* Resolved view of a dimension: the ordered category IDs and a map from
* category ID → its position (0-based). The position is what gets multiplied
* by the dimension's stride when scattering values.
*/
interface ResolvedDimension {
id: string;
column: DimensionColumn;
categories: string[];
position: Map<string, number>;
}
function resolveDimension(id: string, column: DimensionColumn): ResolvedDimension {
let categories: string[];
if (column.categoryOrder && column.categoryOrder.length > 0) {
// Honor an explicit order, but verify every observed value is covered.
const known = new Set(column.categoryOrder);
for (const v of column.values) {
if (!known.has(v)) {
throw new CubeBuilderError(`Dimension "${id}": value "${v}" not present in categoryOrder`);
}
}
categories = column.categoryOrder.slice();
} else {
// First-seen order, deduplicated.
const seen = new Set<string>();
categories = [];
for (const v of column.values) {
if (v == null) {
throw new CubeBuilderError(
`Dimension "${id}": contains a null category value; dimensions cannot be null`,
);
}
if (!seen.has(v)) {
seen.add(v);
categories.push(v);
}
}
}
const position = new Map<string, number>();
categories.forEach((c, i) => position.set(c, i));
return { id, column, categories, position };
}
// ---------------------------------------------------------------------------
// Dimension block
// ---------------------------------------------------------------------------
/** Build the JSON-stat `dimension` object and the `size` array. */
function buildDimensionBlock(resolved: ResolvedDimension[]): {
dimension: Record<string, JsonStatDimension>;
size: number[];
} {
const dimension: Record<string, JsonStatDimension> = {};
const size: number[] = [];
for (const r of resolved) {
const cat: JsonStatCategory = {};
// index: array form is the canonical, simplest representation.
cat.index = r.categories.slice();
if (r.column.categoryLabels) cat.label = { ...r.column.categoryLabels };
if (r.column.categoryUnits) cat.unit = { ...r.column.categoryUnits };
if (r.column.categoryCoordinates) cat.coordinates = { ...r.column.categoryCoordinates };
if (r.column.categoryChild) cat.child = { ...r.column.categoryChild };
const dim: JsonStatDimension = { category: cat };
if (r.column.label) dim.label = r.column.label;
if (r.column.href) dim.href = r.column.href;
dimension[r.id] = dim;
size.push(r.categories.length);
}
return { dimension, size };
}
// ---------------------------------------------------------------------------
// Value scattering (long table → row-major cube)
// ---------------------------------------------------------------------------
/**
* Scatter measure values into a dense array of length `total` (null-filled)
* using row-major positions computed from the resolved dimensions.
*
* If two observations map to the same cell, the *last* one wins and a warning
* is surfaced via the returned diagnostics (the cube model assumes one value
* per cell; duplicates indicate an ill-formed source).
*/
function scatterValues(
obs: Observations,
resolved: ResolvedDimension[],
size: number[],
): { dense: (number | null)[]; duplicates: number } {
const total = totalCells(size);
const dense: (number | null)[] = new Array(total).fill(null);
const seen = new Uint8Array(total); // 0 = empty, 1 = filled
let duplicates = 0;
const n = obs.measure.values.length;
const k = resolved.length;
for (let row = 0; row < n; row++) {
const indices = new Array<number>(k);
for (let d = 0; d < k; d++) {
const r = resolved[d];
const v = r.column.values[row];
const p = r.position.get(v);
if (p === undefined) {
// resolveDimension guarantees coverage when categoryOrder is used, but
// guard regardless.
throw new CubeBuilderError(
`Dimension "${r.id}": value "${v}" has no position (row ${row})`,
);
}
indices[d] = p;
}
const pos = flatPosition(indices, size);
if (seen[pos] === 1) duplicates++;
seen[pos] = 1;
dense[pos] = obs.measure.values[row];
}
return { dense, duplicates };
}
// ---------------------------------------------------------------------------
// Value form selection (dense vs sparse)
// ---------------------------------------------------------------------------
/**
* Decide between dense (array) and sparse (object) `value` representation.
*
* The object (sparse) `value` form is preferred when many cells are null. The
* threshold is the null ratio above which the object form is more compact.
*/
function chooseValueForm(
dense: (number | null)[],
requested: "auto" | "dense" | "sparse",
threshold: number,
): { value: JsonStatValue; form: "dense" | "sparse"; nullRatio: number } {
const total = dense.length;
const nullCount = dense.reduce<number>((acc, v) => acc + (v === null ? 1 : 0), 0);
const nullRatio = total === 0 ? 0 : nullCount / total;
let form: "dense" | "sparse";
if (requested === "dense") form = "dense";
else if (requested === "sparse") form = "sparse";
else form = nullRatio > threshold ? "sparse" : "dense";
if (form === "dense") {
return { value: dense, form, nullRatio };
}
// Sparse object: only non-null cells, keyed by stringified flat position.
const obj: Record<string, number> = {};
for (let i = 0; i < total; i++) {
const v = dense[i];
if (v !== null) obj[String(i)] = v;
}
return { value: obj, form, nullRatio };
}
// ---------------------------------------------------------------------------
// Status emission
// ---------------------------------------------------------------------------
/**
* Emit `status` in the most compact correct form.
*
* - If all statuses are identical and non-empty → string form.
* - Else if few distinct non-default statuses → object form (sparse status).
* - Else → array form (one entry per cell, aligned with dense `value`).
*
* The caller passes the *dense* per-cell status array (length = total cells).
*/
function emitStatus(
perCell: string[],
requested: "auto" | "array" | "string" | "object" | "none",
): JsonStatStatus | undefined {
if (requested === "none") return undefined;
const total = perCell.length;
if (total === 0) return undefined;
// Collect distinct statuses and whether they're uniform.
const first = perCell[0];
let uniform = true;
const distinct = new Set<string>();
for (const s of perCell) {
distinct.add(s);
if (s !== first) uniform = false;
}
if (requested === "string") return first; // caller's explicit choice
if (requested === "array") return perCell.slice();
if (requested === "object") {
return sparseStatus(perCell);
}
// auto:
if (uniform && first !== "") return first;
// If most cells share the dominant status, sparse object form is compact.
const counts = new Map<string, number>();
for (const s of perCell) counts.set(s, (counts.get(s) ?? 0) + 1);
let dominant = "";
let dominantCount = -1;
for (const [s, c] of counts) {
if (c > dominantCount) {
dominant = s;
dominantCount = c;
}
}
// Use object form when fewer than ~40% of cells deviate from the dominant.
const nonDominant = total - dominantCount;
if (dominant !== "" && nonDominant / total < 0.4) {
return sparseStatus(perCell, dominant);
}
return perCell.slice();
}
/** Build the sparse object status form. Omits `defaultStatus` entries. */
function sparseStatus(perCell: string[], defaultStatus?: string): Record<string, string> {
const obj: Record<string, string> = {};
for (let i = 0; i < perCell.length; i++) {
const s = perCell[i];
if (s === "" || s === defaultStatus) continue;
obj[String(i)] = s;
}
return obj;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/** Validate the structural invariants the builder relies on. */
function validate(obs: Observations, resolved: ResolvedDimension[]): void {
const n = obs.measure.values.length;
for (const r of resolved) {
if (r.column.values.length !== n) {
throw new CubeBuilderError(
`Dimension "${r.id}": ${r.column.values.length} values ≠ ${n} measure values`,
);
}
}
if (obs.status && obs.status.values.length !== n) {
throw new CubeBuilderError(
`Status column: ${obs.status.values.length} values ≠ ${n} measure values`,
);
}
// Every model.dimensionIds entry must have a column, and vice versa.
const modelIds = new Set(obs.model.dimensionIds);
const colIds = new Set(Object.keys(obs.dimensions));
for (const id of modelIds) {
if (!colIds.has(id)) {
throw new CubeBuilderError(`model.dimensionIds lists "${id}" but no column exists`);
}
}
for (const id of colIds) {
if (!modelIds.has(id)) {
throw new CubeBuilderError(`Column "${id}" is not listed in model.dimensionIds`);
}
}
}
// ---------------------------------------------------------------------------
// Build result & public API
// ---------------------------------------------------------------------------
export interface BuildResult {
dataset: JsonStatDataset;
/** Diagnostics from the build (non-fatal observations). */
diagnostics: {
valueForm: "dense" | "sparse";
nullRatio: number;
duplicates: number;
cellCount: number;
};
}
/**
* Build a JSON-stat [`Dataset`](../model/jsonstat.ts) from an
* [`Observations`](../model/ir.ts) IR.
*
* @throws [`CubeBuilderError`](#cubebuildererror) on invariant violations.
*/
export function buildDataset(obs: Observations, options: BuildOptions = {}): BuildResult {
const model = obs.model;
const dimensionIds = model.dimensionIds;
// Resolve dimensions in the model-declared order (this defines stride order).
const resolved = dimensionIds.map((id) => {
const col = obs.dimensions[id];
if (!col) throw new CubeBuilderError(`Missing dimension column "${id}"`);
return resolveDimension(id, col);
});
validate(obs, resolved);
const { dimension, size } = buildDimensionBlock(resolved);
// Scatter measure values into the dense row-major array.
const { dense, duplicates } = scatterValues(obs, resolved, size);
// Choose value form.
const valueForm = options.valueForm ?? model.valueForm ?? "auto";
const threshold = options.sparseThreshold ?? model.sparseThreshold ?? 0.5;
const { value, form, nullRatio } = chooseValueForm(dense, valueForm, threshold);
// Status: if present, build a dense per-cell status array aligned with `value`.
let status: JsonStatStatus | undefined;
if (obs.status) {
const statusForm = options.statusForm ?? model.statusForm ?? "auto";
if (statusForm !== "none") {
status = buildPerCellStatus(obs, resolved, size, statusForm);
}
}
const dataset: JsonStatDataset = {
version: "2.0",
class: "dataset",
id: dimensionIds.slice(),
size,
dimension,
value,
};
// Roles (only attach defined role keys, and only if non-empty).
const role = filterRoles(model.roles);
if (role) dataset.role = role;
// Dataset-level metadata.
const meta = options.meta ?? model.meta;
if (meta) {
if (meta.label) dataset.label = meta.label;
if (meta.source) dataset.source = meta.source;
if (meta.updated) dataset.updated = meta.updated;
if (meta.href) dataset.href = meta.href;
if (meta.extension) dataset.extension = meta.extension;
}
if (status) dataset.status = status;
return {
dataset,
diagnostics: {
valueForm: form,
nullRatio,
duplicates,
cellCount: dense.length,
},
};
}
/**
* Build a per-cell status array by scattering the status column into row-major
* order, then reduce to the requested form. Cells with no observation default
* to "" (empty), which the emitter treats as "no status".
*/
function buildPerCellStatus(
obs: Observations,
resolved: ResolvedDimension[],
size: number[],
statusForm: "auto" | "array" | "string" | "object" | "none",
): JsonStatStatus | undefined {
const total = totalCells(size);
const perCell: string[] = new Array(total).fill("");
const n = obs.measure.values.length;
const k = resolved.length;
const statusVals = obs.status?.values;
if (!statusVals) return undefined;
for (let row = 0; row < n; row++) {
const indices = new Array<number>(k);
for (let d = 0; d < k; d++) {
indices[d] = resolved[d].position.get(resolved[d].column.values[row])!;
}
const pos = flatPosition(indices, size);
perCell[pos] = statusVals[row] ?? "";
}
return emitStatus(perCell, statusForm);
}
/** Return a role object with only non-empty arrays, or undefined if all empty. */
function filterRoles(roles?: JsonStatRole): JsonStatRole | undefined {
if (!roles) return undefined;
const out: JsonStatRole = {};
let any = false;
for (const key of ["time", "geo", "metric"] as const) {
const arr = roles[key];
if (arr && arr.length > 0) {
out[key] = arr.slice();
any = true;
}
}
return any ? out : undefined;
}
/**
* Convenience: build and return *just* the dataset (no diagnostics).
* Useful for the common programmatic case.
*/
export function toDataset(obs: Observations, options?: BuildOptions): JsonStatDataset {
return buildDataset(obs, options).dataset;
}
// Re-export for consumers who want to enumerate cells themselves.
export { enumerateCells };