ggaction 0.0.16 → 0.0.17
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/CHANGELOG.md +20 -0
- package/README.md +2 -1
- package/knowledge/action-cards.json +1 -1
- package/knowledge/intent-taxonomy.json +1 -1
- package/knowledge/mcp-resources.json +1 -1
- package/package.json +8 -2
- package/src/actions/data/bin.js +1 -1
- package/src/actions/data/create.js +18 -5
- package/src/actions/data/density.js +1 -1
- package/src/actions/data/derived.js +15 -1
- package/src/actions/data/edit.js +19 -6
- package/src/actions/data/filter.js +15 -4
- package/src/actions/data/index.js +3 -0
- package/src/actions/data/interval.js +2 -0
- package/src/actions/data/regression.js +4 -1
- package/src/actions/data/revise.js +37 -5
- package/src/actions/data/shared.js +30 -2
- package/src/actions/data/sort.js +21 -0
- package/src/actions/data/summary.js +1 -1
- package/src/actions/encodings/angle.js +4 -1
- package/src/actions/encodings/appearance.js +6 -2
- package/src/actions/encodings/color/continuous.js +4 -1
- package/src/actions/encodings/color/index.js +4 -1
- package/src/actions/encodings/offset.js +4 -1
- package/src/actions/encodings/position/resolve.js +6 -1
- package/src/actions/encodings/ranged.js +6 -1
- package/src/actions/encodings/ruleAppearance.js +4 -1
- package/src/actions/encodings/stroke.js +4 -1
- package/src/actions/encodings/strokeDash.js +6 -1
- package/src/actions/encodings/text.js +6 -1
- package/src/actions/marks/bar/create.js +8 -1
- package/src/actions/marks/bar/edit.js +12 -5
- package/src/actions/marks/point/create.js +8 -1
- package/src/actions/marks/point/edit.js +12 -5
- package/src/actions/marks/point/materialize.js +4 -2
- package/src/actions/marks/rect/actions.js +23 -8
- package/src/actions/marks/rule/actions.js +24 -8
- package/src/actions/marks/text/actions.js +25 -6
- package/src/actions/marks/tick/actions.js +23 -8
- package/src/actions/primitives/semanticAction.js +37 -3
- package/src/actions/primitives/semanticValidation/dataset.js +13 -1
- package/src/actions/primitives/semanticValidation/layer.js +22 -2
- package/src/actions/primitives/semanticValidation/scale.js +6 -0
- package/src/actions/regression/create.js +25 -4
- package/src/actions/regression/edit.js +47 -12
- package/src/actions/regression/follow.js +22 -0
- package/src/actions/scales/channels.js +11 -11
- package/src/actions/scales/consumers/common.js +2 -1
- package/src/actions/scales/create.js +2 -1
- package/src/actions/scales/definitions.js +1 -1
- package/src/actions/scales/edit.js +1 -1
- package/src/actions/scales/parallel.js +1 -1
- package/src/actions/scales/patch.js +1 -1
- package/src/actions/scales/preview.js +28 -1
- package/src/actions/theme/reconcile.js +2 -0
- package/src/core/action.js +3 -2
- package/src/core/programState.js +5 -1
- package/src/grammar/bin.js +39 -10
- package/src/grammar/datasetSchema.js +386 -0
- package/src/grammar/density.js +53 -5
- package/src/grammar/filter.js +133 -39
- package/src/grammar/interval.js +32 -1
- package/src/grammar/itemMissing.js +59 -0
- package/src/grammar/regression/derive.js +57 -16
- package/src/grammar/regression/index.js +1 -0
- package/src/grammar/regression/models.js +51 -22
- package/src/grammar/regression/parameters.js +56 -4
- package/src/grammar/scales/definition.js +7 -0
- package/src/grammar/schemas/semanticPath.js +3 -2
- package/src/grammar/sort.js +88 -0
- package/src/grammar/summary.js +117 -19
- package/src/grammar/transformTopology.js +1 -0
- package/src/grammar/transforms.js +21 -11
- package/src/inspection.js +495 -0
- package/src/inspectionDescriptors.js +3 -0
- package/src/materialization/bars/resolve.js +4 -2
- package/src/materialization/revisionIdentity.js +19 -0
- package/src/materialization/scales/resolve.js +67 -4
- package/src/materialization/text.js +4 -2
- package/src/persistence.js +79 -6
- package/src/version.js +1 -1
- package/types/inspection.d.ts +17 -0
- package/types/program.d.ts +147 -27
|
@@ -58,14 +58,14 @@ function linearPrediction(model, xValue) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function fitLinearGroup(rows, {
|
|
61
|
-
x, y, group, confidenceMethod, level
|
|
61
|
+
x, y, group, confidenceMethod, level, interval
|
|
62
62
|
}) {
|
|
63
63
|
const count = rows.length;
|
|
64
64
|
const groupLabel = group === undefined ? "all" : String(group);
|
|
65
65
|
const label = `Regression group "${groupLabel}"`;
|
|
66
|
-
if (count < 3) {
|
|
66
|
+
if (count < (interval === false ? 2 : 3)) {
|
|
67
67
|
throw new Error(
|
|
68
|
-
`${label} requires at least three rows.`
|
|
68
|
+
`${label} requires at least ${interval === false ? "two" : "three"} rows.`
|
|
69
69
|
);
|
|
70
70
|
}
|
|
71
71
|
const xValues = rows.map(row => row[x]);
|
|
@@ -148,6 +148,7 @@ function fitLinearGroup(rows, {
|
|
|
148
148
|
normalizedSxx
|
|
149
149
|
});
|
|
150
150
|
if (!normalizedXByDifference) model.normalizedXByDifference = false;
|
|
151
|
+
if (interval === false) return model;
|
|
151
152
|
const residualSumSquares = stableFiniteSquareSum(
|
|
152
153
|
rows.map(row => row[y] - linearPrediction(model, row[x])),
|
|
153
154
|
`${label} residual sum of squares`
|
|
@@ -238,19 +239,19 @@ function polynomialResponse(design, values, size, scale = 1) {
|
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
function fitPolynomialGroup(rows, {
|
|
241
|
-
x, y, group, confidenceMethod, level, degree
|
|
242
|
+
x, y, group, confidenceMethod, level, degree, interval
|
|
242
243
|
}) {
|
|
243
244
|
const count = rows.length;
|
|
244
245
|
const parameterCount = degree + 1;
|
|
245
246
|
const groupLabel = group === undefined ? "all" : String(group);
|
|
246
247
|
const label = `Polynomial regression group "${groupLabel}"`;
|
|
247
248
|
if (
|
|
248
|
-
count < degree + 2 ||
|
|
249
|
+
count < (interval === false ? parameterCount : degree + 2) ||
|
|
249
250
|
new Set(rows.map(row => row[x])).size < parameterCount
|
|
250
251
|
) {
|
|
251
252
|
throw new Error(
|
|
252
253
|
`${label} requires at least ` +
|
|
253
|
-
`${degree + 2} rows and ${parameterCount} distinct x values.`
|
|
254
|
+
`${interval === false ? parameterCount : degree + 2} rows and ${parameterCount} distinct x values.`
|
|
254
255
|
);
|
|
255
256
|
}
|
|
256
257
|
const xValues = rows.map(row => row[x]);
|
|
@@ -289,7 +290,7 @@ function fitPolynomialGroup(rows, {
|
|
|
289
290
|
`${label} coefficient ${index}`
|
|
290
291
|
))
|
|
291
292
|
: scaledCoefficients;
|
|
292
|
-
const inverse = invertSymmetricMatrix(normal);
|
|
293
|
+
const inverse = interval === false ? undefined : invertSymmetricMatrix(normal);
|
|
293
294
|
const fitted = design.map(basis => stableResponse
|
|
294
295
|
? restoreFiniteScale(
|
|
295
296
|
dot(basis, scaledCoefficients),
|
|
@@ -298,12 +299,12 @@ function fitPolynomialGroup(rows, {
|
|
|
298
299
|
)
|
|
299
300
|
: dot(basis, normalizedCoefficients)
|
|
300
301
|
);
|
|
301
|
-
const residualSumSquares = stableFiniteSquareSum(
|
|
302
|
+
const residualSumSquares = interval === false ? undefined : stableFiniteSquareSum(
|
|
302
303
|
fitted.map((value, index) => rows[index][y] - value),
|
|
303
304
|
`${label} residual sum of squares`
|
|
304
305
|
);
|
|
305
306
|
const degreesOfFreedom = count - parameterCount;
|
|
306
|
-
const residualVariance = residualSumSquares / degreesOfFreedom;
|
|
307
|
+
const residualVariance = interval === false ? undefined : residualSumSquares / degreesOfFreedom;
|
|
307
308
|
const model = {
|
|
308
309
|
count,
|
|
309
310
|
degreesOfFreedom,
|
|
@@ -316,13 +317,15 @@ function fitPolynomialGroup(rows, {
|
|
|
316
317
|
normalizedCoefficients,
|
|
317
318
|
center,
|
|
318
319
|
scale,
|
|
319
|
-
inverse,
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
320
|
+
...(inverse === undefined ? {} : { inverse }),
|
|
321
|
+
...(interval === false ? {} : {
|
|
322
|
+
residualSumSquares,
|
|
323
|
+
residualStandardError: Math.sqrt(residualVariance),
|
|
324
|
+
critical: confidenceCriticalValue({
|
|
325
|
+
method: confidenceMethod,
|
|
326
|
+
level,
|
|
327
|
+
degreesOfFreedom
|
|
328
|
+
})
|
|
326
329
|
})
|
|
327
330
|
};
|
|
328
331
|
if (!normalizedXByDifference) model.normalizedXByDifference = false;
|
|
@@ -345,7 +348,9 @@ function evaluatePolynomial(model, xValue) {
|
|
|
345
348
|
model.responseScale,
|
|
346
349
|
"Polynomial regression prediction"
|
|
347
350
|
),
|
|
348
|
-
|
|
351
|
+
...(model.inverse === undefined ? {} : {
|
|
352
|
+
leverage: dot(basis, model.inverse.map(row => dot(row, basis)))
|
|
353
|
+
})
|
|
349
354
|
};
|
|
350
355
|
}
|
|
351
356
|
|
|
@@ -420,7 +425,29 @@ function fitLoessGroup(rows, { x, y, group, span }) {
|
|
|
420
425
|
neighborIndices: weighted.map(item => item.index)
|
|
421
426
|
};
|
|
422
427
|
});
|
|
423
|
-
return { count: rows.length, span, neighborCount, fits };
|
|
428
|
+
return { count: rows.length, span, neighborCount, fits, rows, x, y, xScale, yScale };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function predictLoessAt(model, xValue) {
|
|
432
|
+
const found = model.fits.find(fit => fit.x === xValue);
|
|
433
|
+
if (found !== undefined) return found.prediction;
|
|
434
|
+
const neighbors = model.rows.map((row, index) => ({
|
|
435
|
+
row,
|
|
436
|
+
index,
|
|
437
|
+
distance: Math.abs(row[model.x] / model.xScale - xValue / model.xScale)
|
|
438
|
+
})).sort((left, right) => left.distance - right.distance || left.index - right.index)
|
|
439
|
+
.slice(0, model.neighborCount);
|
|
440
|
+
const radius = neighbors.at(-1).distance;
|
|
441
|
+
const weighted = neighbors.map(neighbor => ({
|
|
442
|
+
...neighbor,
|
|
443
|
+
weight: radius === 0 ? 1 : (1 - (neighbor.distance / radius) ** 3) ** 3
|
|
444
|
+
}));
|
|
445
|
+
const ordinary = weightedPrediction(weighted, model.x, model.y, xValue);
|
|
446
|
+
return Number.isFinite(ordinary) ? ordinary : restoreFiniteScale(
|
|
447
|
+
weightedPrediction(weighted, model.x, model.y, xValue, model.xScale, model.yScale),
|
|
448
|
+
model.yScale,
|
|
449
|
+
"LOESS regression prediction"
|
|
450
|
+
);
|
|
424
451
|
}
|
|
425
452
|
|
|
426
453
|
export function fitRegressionGroup(rows, { x, y, group, parameters }) {
|
|
@@ -430,7 +457,8 @@ export function fitRegressionGroup(rows, { x, y, group, parameters }) {
|
|
|
430
457
|
y,
|
|
431
458
|
group,
|
|
432
459
|
confidenceMethod: parameters.confidenceMethod,
|
|
433
|
-
level: parameters.level
|
|
460
|
+
level: parameters.level,
|
|
461
|
+
interval: parameters.interval
|
|
434
462
|
});
|
|
435
463
|
}
|
|
436
464
|
if (parameters.method === "polynomial") {
|
|
@@ -440,7 +468,8 @@ export function fitRegressionGroup(rows, { x, y, group, parameters }) {
|
|
|
440
468
|
group,
|
|
441
469
|
confidenceMethod: parameters.confidenceMethod,
|
|
442
470
|
level: parameters.level,
|
|
443
|
-
degree: parameters.degree
|
|
471
|
+
degree: parameters.degree,
|
|
472
|
+
interval: parameters.interval
|
|
444
473
|
});
|
|
445
474
|
}
|
|
446
475
|
return fitLoessGroup(rows, { x, y, group, span: parameters.span });
|
|
@@ -454,8 +483,8 @@ export function predictRegressionAt(model, xValue, parameters) {
|
|
|
454
483
|
? linearPrediction(model, xValue)
|
|
455
484
|
: parameters.method === "polynomial"
|
|
456
485
|
? polynomial.prediction
|
|
457
|
-
: model
|
|
458
|
-
if (parameters.method === "loess") return { prediction };
|
|
486
|
+
: predictLoessAt(model, xValue);
|
|
487
|
+
if (parameters.method === "loess" || parameters.interval === false) return { prediction };
|
|
459
488
|
const leverage = parameters.method === "linear"
|
|
460
489
|
? model.normalizedSxx !== undefined
|
|
461
490
|
? 1 / model.count + (
|
|
@@ -1,10 +1,39 @@
|
|
|
1
|
-
import { cloneAndFreeze } from "../../core/immutable.js";
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../../core/immutable.js";
|
|
2
2
|
import { validateGeneratedItemLimit } from "../../core/validation.js";
|
|
3
3
|
import { normalizeConfidenceInterval } from
|
|
4
4
|
"../statistics/confidenceInterval.js";
|
|
5
5
|
|
|
6
6
|
const MAX_POLYNOMIAL_DEGREE = 32;
|
|
7
7
|
|
|
8
|
+
export function normalizeRegressionPredict(predict) {
|
|
9
|
+
if (predict === undefined) return undefined;
|
|
10
|
+
if (predict === false) throw new TypeError("Regression predict false is only valid in edits.");
|
|
11
|
+
if (!isPlainObject(predict)) {
|
|
12
|
+
throw new TypeError("Regression predict must be a plain object.");
|
|
13
|
+
}
|
|
14
|
+
const unknown = Object.keys(predict).find(key => !["values", "domain", "steps"].includes(key));
|
|
15
|
+
if (unknown !== undefined) throw new Error(`Unknown regression predict property "${unknown}".`);
|
|
16
|
+
const usesValues = Object.hasOwn(predict, "values");
|
|
17
|
+
const usesDomain = Object.hasOwn(predict, "domain") || Object.hasOwn(predict, "steps");
|
|
18
|
+
if (usesValues === usesDomain) throw new Error("Regression predict requires values or domain with steps.");
|
|
19
|
+
if (usesValues) {
|
|
20
|
+
const values = predict.values;
|
|
21
|
+
if (!Array.isArray(values) || values.length === 0 || values.some((value, index) =>
|
|
22
|
+
!Number.isFinite(value) || (index > 0 && !(value > values[index - 1]))
|
|
23
|
+
)) throw new TypeError("Regression predict values must be finite, unique, and strictly ascending.");
|
|
24
|
+
return cloneAndFreeze({ values: [...values] });
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(predict.domain) || predict.domain.length !== 2 ||
|
|
27
|
+
!predict.domain.every(Number.isFinite) || !(predict.domain[0] < predict.domain[1])) {
|
|
28
|
+
throw new TypeError("Regression predict domain must be two increasing finite numbers.");
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(predict.steps) || predict.steps < 2) {
|
|
31
|
+
throw new RangeError("Regression predict steps must be an integer of at least two.");
|
|
32
|
+
}
|
|
33
|
+
validateGeneratedItemLimit(predict.steps, "Regression predict steps");
|
|
34
|
+
return cloneAndFreeze({ domain: [...predict.domain], steps: predict.steps });
|
|
35
|
+
}
|
|
36
|
+
|
|
8
37
|
export function requireRegressionField(field, label) {
|
|
9
38
|
if (typeof field !== "string" || field.length === 0) {
|
|
10
39
|
throw new TypeError(`${label} must be a non-empty string.`);
|
|
@@ -45,6 +74,21 @@ export function normalizeRegressionParameters({
|
|
|
45
74
|
if (span !== undefined) {
|
|
46
75
|
throw new Error("Regression span requires the loess method.");
|
|
47
76
|
}
|
|
77
|
+
if (interval === false) {
|
|
78
|
+
if (confidenceMethod !== undefined || level !== undefined || confidence !== undefined) {
|
|
79
|
+
throw new Error("Regression interval false cannot include confidence options.");
|
|
80
|
+
}
|
|
81
|
+
if (method === "polynomial") {
|
|
82
|
+
const resolvedDegree = degree ?? 2;
|
|
83
|
+
if (!Number.isInteger(resolvedDegree) || resolvedDegree < 1) {
|
|
84
|
+
throw new RangeError("Regression polynomial degree must be a positive integer.");
|
|
85
|
+
}
|
|
86
|
+
validateGeneratedItemLimit(resolvedDegree, "Regression polynomial degree", MAX_POLYNOMIAL_DEGREE);
|
|
87
|
+
return cloneAndFreeze({ method, degree: resolvedDegree, interval: false });
|
|
88
|
+
}
|
|
89
|
+
if (degree !== undefined) throw new Error("Regression degree requires the polynomial method.");
|
|
90
|
+
return cloneAndFreeze({ method, interval: false });
|
|
91
|
+
}
|
|
48
92
|
if (level !== undefined && confidence !== undefined && level !== confidence) {
|
|
49
93
|
throw new Error(
|
|
50
94
|
"Regression level and confidence alias must match when both are provided."
|
|
@@ -96,7 +140,7 @@ export function validateRegressionTransform(transform) {
|
|
|
96
140
|
const supported = [
|
|
97
141
|
"type", "method", "x", "y", "groupBy", "confidenceMethod", "level",
|
|
98
142
|
"confidence", "interval",
|
|
99
|
-
"degree", "span"
|
|
143
|
+
"degree", "span", "predict", "missing"
|
|
100
144
|
];
|
|
101
145
|
const unknown = Object.keys(transform).find(key => !supported.includes(key));
|
|
102
146
|
if (unknown !== undefined) {
|
|
@@ -127,9 +171,14 @@ export function validateRegressionTransform(transform) {
|
|
|
127
171
|
confidence: transform.confidence,
|
|
128
172
|
interval: transform.interval
|
|
129
173
|
});
|
|
174
|
+
normalizeRegressionPredict(transform.predict);
|
|
175
|
+
if (transform.missing !== undefined && !["error", "drop"].includes(transform.missing)) {
|
|
176
|
+
throw new Error('Regression missing must be "error" or "drop".');
|
|
177
|
+
}
|
|
130
178
|
if (normalized.method === "loess") return transform;
|
|
131
179
|
if (
|
|
132
180
|
transform.confidence === undefined &&
|
|
181
|
+
transform.interval !== false &&
|
|
133
182
|
(transform.confidenceMethod === undefined || transform.level === undefined)
|
|
134
183
|
) {
|
|
135
184
|
throw new Error("Regression confidence provenance requires method and level.");
|
|
@@ -142,6 +191,7 @@ export function validateRegressionTransform(transform) {
|
|
|
142
191
|
|
|
143
192
|
export function normalizeRegressionTransform(args = {}) {
|
|
144
193
|
const parameters = normalizeRegressionParameters(args);
|
|
194
|
+
const predict = normalizeRegressionPredict(args.predict);
|
|
145
195
|
const transform = {
|
|
146
196
|
type: "regression",
|
|
147
197
|
method: parameters.method,
|
|
@@ -153,11 +203,13 @@ export function normalizeRegressionTransform(args = {}) {
|
|
|
153
203
|
: {}),
|
|
154
204
|
...(parameters.method === "loess"
|
|
155
205
|
? { span: parameters.span }
|
|
156
|
-
: {
|
|
206
|
+
: parameters.interval === false ? { interval: false } : {
|
|
157
207
|
confidenceMethod: parameters.confidenceMethod,
|
|
158
208
|
level: parameters.level,
|
|
159
209
|
interval: parameters.interval
|
|
160
|
-
})
|
|
210
|
+
}),
|
|
211
|
+
...(predict === undefined ? {} : { predict }),
|
|
212
|
+
...(args.missing === undefined ? {} : { missing: args.missing })
|
|
161
213
|
};
|
|
162
214
|
validateRegressionTransform(transform);
|
|
163
215
|
return cloneAndFreeze(transform);
|
|
@@ -108,6 +108,13 @@ export function normalizeScaleDefinition({
|
|
|
108
108
|
domain: validateDomain(type, rawDomain),
|
|
109
109
|
range: validateRange(type, rawRange)
|
|
110
110
|
};
|
|
111
|
+
const emptyDomain = retainedValue(previous, patch, defaults, "emptyDomain", typeChanged);
|
|
112
|
+
if (emptyDomain !== undefined) {
|
|
113
|
+
if (!["preserve", "require-explicit"].includes(emptyDomain)) {
|
|
114
|
+
throw new Error('Scale emptyDomain must be "preserve" or "require-explicit".');
|
|
115
|
+
}
|
|
116
|
+
definition.emptyDomain = emptyDomain;
|
|
117
|
+
}
|
|
111
118
|
|
|
112
119
|
for (const property of BOOLEAN_PROPERTIES) {
|
|
113
120
|
let value = retainedValue(previous, patch, defaults, property, typeChanged);
|
|
@@ -49,7 +49,7 @@ const ENCODING_PATHS = Object.freeze([
|
|
|
49
49
|
const ENTITY_PATHS = Object.freeze({
|
|
50
50
|
dataset: {
|
|
51
51
|
collection: "datasets",
|
|
52
|
-
properties: new Set(["source", "transform", "values"])
|
|
52
|
+
properties: new Set(["source", "transform", "schema", "values"])
|
|
53
53
|
},
|
|
54
54
|
layer: {
|
|
55
55
|
collection: "layers",
|
|
@@ -64,6 +64,7 @@ const ENTITY_PATHS = Object.freeze({
|
|
|
64
64
|
"transform",
|
|
65
65
|
"mark.type",
|
|
66
66
|
"mark.missing",
|
|
67
|
+
"derivedBindings.regression",
|
|
67
68
|
"layout.mode",
|
|
68
69
|
...ENCODING_PATHS
|
|
69
70
|
])
|
|
@@ -71,7 +72,7 @@ const ENTITY_PATHS = Object.freeze({
|
|
|
71
72
|
scale: {
|
|
72
73
|
collection: "scales",
|
|
73
74
|
properties: new Set([
|
|
74
|
-
"type", "domain", "range", "nice", "zero", "clamp", "reverse",
|
|
75
|
+
"type", "domain", "range", "emptyDomain", "nice", "zero", "clamp", "reverse",
|
|
75
76
|
"unknown", "base", "exponent", "constant", "interpolate", "radialMapping", "midpoint",
|
|
76
77
|
"paddingInner", "paddingOuter", "padding", "align"
|
|
77
78
|
])
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
|
|
2
|
+
import { normalizeTemporalValue } from "./scales/fields.js";
|
|
3
|
+
|
|
4
|
+
const ORDERS = new Set(["ascending", "descending"]);
|
|
5
|
+
const NULLS = new Set(["first", "last"]);
|
|
6
|
+
const TEMPORAL_UNITS = new Set(["year", "timestamp"]);
|
|
7
|
+
|
|
8
|
+
export function normalizeSortTransform({ sortBy }) {
|
|
9
|
+
if (!Array.isArray(sortBy) || sortBy.length === 0 ||
|
|
10
|
+
Array.from({ length: sortBy.length }, (_, index) => index).some(index => !Object.hasOwn(sortBy, index))) {
|
|
11
|
+
throw new TypeError("Sorted data sortBy must be a non-empty dense array.");
|
|
12
|
+
}
|
|
13
|
+
const keys = sortBy.map((key, index) => {
|
|
14
|
+
if (!isPlainObject(key)) throw new TypeError(`Sort key ${index} must be a plain object.`);
|
|
15
|
+
const unknown = Object.keys(key).find(name => !["field", "order", "nulls", "temporalUnit"].includes(name));
|
|
16
|
+
if (unknown !== undefined) throw new Error(`Unknown sort key property "${unknown}".`);
|
|
17
|
+
if (typeof key.field !== "string" || key.field.length === 0) {
|
|
18
|
+
throw new TypeError(`Sort key ${index} field must be a non-empty string.`);
|
|
19
|
+
}
|
|
20
|
+
const order = key.order ?? "ascending";
|
|
21
|
+
const nulls = key.nulls ?? "last";
|
|
22
|
+
if (!ORDERS.has(order)) throw new Error(`Unsupported sort order "${order}".`);
|
|
23
|
+
if (!NULLS.has(nulls)) throw new Error(`Unsupported sort null placement "${nulls}".`);
|
|
24
|
+
if (key.temporalUnit !== undefined && !TEMPORAL_UNITS.has(key.temporalUnit)) {
|
|
25
|
+
throw new Error(`Unsupported sort temporalUnit "${key.temporalUnit}".`);
|
|
26
|
+
}
|
|
27
|
+
return { field: key.field, order, nulls, ...(key.temporalUnit === undefined ? {} : { temporalUnit: key.temporalUnit }) };
|
|
28
|
+
});
|
|
29
|
+
if (new Set(keys.map(key => key.field)).size !== keys.length) {
|
|
30
|
+
throw new Error("Sorted data sortBy fields must be unique.");
|
|
31
|
+
}
|
|
32
|
+
return cloneAndFreeze({ type: "sort", sortBy: keys });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateSortTransform(transform) {
|
|
36
|
+
if (!isPlainObject(transform)) throw new TypeError("Sort transform must be a plain object.");
|
|
37
|
+
const unknown = Object.keys(transform).find(key => !["type", "sortBy"].includes(key));
|
|
38
|
+
if (unknown !== undefined) throw new Error(`Unknown sort transform property "${unknown}".`);
|
|
39
|
+
if (transform.type !== "sort") throw new Error('Sort transform type must be "sort".');
|
|
40
|
+
normalizeSortTransform(transform);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compareValues(left, right) {
|
|
44
|
+
if (left === right) return 0;
|
|
45
|
+
return left < right ? -1 : 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizedKey(value, key, rowIndex) {
|
|
49
|
+
if (value === null || value === undefined) return { missing: true };
|
|
50
|
+
if (key.temporalUnit !== undefined) {
|
|
51
|
+
return { missing: false, value: normalizeTemporalValue(value, key.field, rowIndex, key.temporalUnit) };
|
|
52
|
+
}
|
|
53
|
+
if (!["number", "string", "boolean"].includes(typeof value) ||
|
|
54
|
+
(typeof value === "number" && !Number.isFinite(value))) {
|
|
55
|
+
throw new TypeError(`Sort field "${key.field}" requires finite number, string, boolean, or missing values at row ${rowIndex}.`);
|
|
56
|
+
}
|
|
57
|
+
return { missing: false, value, type: typeof value };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function deriveSortedRows(values, transform) {
|
|
61
|
+
if (!Array.isArray(values)) throw new TypeError("Sort source values must be an array.");
|
|
62
|
+
validateSortTransform(transform);
|
|
63
|
+
const decorated = values.map((row, index) => ({
|
|
64
|
+
row,
|
|
65
|
+
index,
|
|
66
|
+
keys: transform.sortBy.map(key => normalizedKey(row[key.field], key, index))
|
|
67
|
+
}));
|
|
68
|
+
for (let keyIndex = 0; keyIndex < transform.sortBy.length; keyIndex += 1) {
|
|
69
|
+
const key = transform.sortBy[keyIndex];
|
|
70
|
+
const types = new Set(decorated.map(entry => entry.keys[keyIndex]).filter(value => !value.missing).map(value => value.type ?? "number"));
|
|
71
|
+
if (types.size > 1) throw new TypeError(`Sort field "${key.field}" contains mixed non-missing types.`);
|
|
72
|
+
}
|
|
73
|
+
decorated.sort((left, right) => {
|
|
74
|
+
for (let index = 0; index < transform.sortBy.length; index += 1) {
|
|
75
|
+
const key = transform.sortBy[index];
|
|
76
|
+
const a = left.keys[index];
|
|
77
|
+
const b = right.keys[index];
|
|
78
|
+
if (a.missing || b.missing) {
|
|
79
|
+
if (a.missing !== b.missing) return a.missing === (key.nulls === "first") ? -1 : 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const compared = compareValues(a.value, b.value);
|
|
83
|
+
if (compared !== 0) return key.order === "ascending" ? compared : -compared;
|
|
84
|
+
}
|
|
85
|
+
return left.index - right.index;
|
|
86
|
+
});
|
|
87
|
+
return decorated.map(entry => entry.row);
|
|
88
|
+
}
|
package/src/grammar/summary.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
} from "./weightedStatistics.js";
|
|
17
17
|
|
|
18
18
|
const TRANSFORM_KEYS = Object.freeze([
|
|
19
|
-
"type", "groupBy", "aggregates", "members", "weight"
|
|
19
|
+
"type", "groupBy", "aggregates", "members", "weight", "missing", "empty"
|
|
20
20
|
]);
|
|
21
21
|
const AGGREGATE_KEYS = Object.freeze(["op", "field", "as"]);
|
|
22
22
|
const NOMINAL_OPERATIONS = new Set(["distinct", "valid", "missing"]);
|
|
@@ -51,7 +51,7 @@ function normalizeAggregate(value) {
|
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
export function normalizeSummaryTransform({ groupBy, aggregates, members, weight } = {}) {
|
|
54
|
+
export function normalizeSummaryTransform({ groupBy, aggregates, members, weight, missing, empty } = {}) {
|
|
55
55
|
const transform = {
|
|
56
56
|
type: "summary",
|
|
57
57
|
groupBy: normalizeGroupBy(groupBy),
|
|
@@ -61,7 +61,9 @@ export function normalizeSummaryTransform({ groupBy, aggregates, members, weight
|
|
|
61
61
|
...(members === undefined ? {} : { members }),
|
|
62
62
|
...(weight === undefined
|
|
63
63
|
? {}
|
|
64
|
-
: { weight: normalizeStatisticalWeight(weight, "Summary weight") })
|
|
64
|
+
: { weight: normalizeStatisticalWeight(weight, "Summary weight") }),
|
|
65
|
+
...(missing === undefined ? {} : { missing }),
|
|
66
|
+
...(empty === undefined ? {} : { empty })
|
|
65
67
|
};
|
|
66
68
|
validateSummaryTransform(transform);
|
|
67
69
|
return cloneAndFreeze(transform);
|
|
@@ -120,6 +122,12 @@ export function validateSummaryTransform(transform) {
|
|
|
120
122
|
if (transform.weight !== undefined) {
|
|
121
123
|
normalizeStatisticalWeight(transform.weight, "Summary weight");
|
|
122
124
|
}
|
|
125
|
+
if (transform.missing !== undefined && !["error", "drop"].includes(transform.missing)) {
|
|
126
|
+
throw new Error('Summary missing must be "error" or "drop".');
|
|
127
|
+
}
|
|
128
|
+
if (transform.empty !== undefined && !["null", "identity"].includes(transform.empty)) {
|
|
129
|
+
throw new Error('Summary empty must be "null" or "identity".');
|
|
130
|
+
}
|
|
123
131
|
return transform;
|
|
124
132
|
}
|
|
125
133
|
|
|
@@ -169,12 +177,13 @@ function validateAggregateValues(rows, aggregate) {
|
|
|
169
177
|
export function deriveSummaryRows(rows, transform) {
|
|
170
178
|
validateSummaryTransform(transform);
|
|
171
179
|
requireSourceFields(rows, transform);
|
|
180
|
+
const explicitWeighted = transform.weight !== undefined && transform.missing !== undefined;
|
|
172
181
|
let weightEntries;
|
|
173
182
|
if (transform.weight === undefined) {
|
|
174
183
|
for (const aggregate of transform.aggregates) {
|
|
175
184
|
validateAggregateValues(rows, aggregate);
|
|
176
185
|
}
|
|
177
|
-
} else {
|
|
186
|
+
} else if (!explicitWeighted) {
|
|
178
187
|
weightEntries = readStatisticalWeights(rows, transform.weight, "Summary").entries;
|
|
179
188
|
validateWeightedNumericFields(
|
|
180
189
|
weightEntries,
|
|
@@ -183,6 +192,37 @@ export function deriveSummaryRows(rows, transform) {
|
|
|
183
192
|
))],
|
|
184
193
|
"Summary"
|
|
185
194
|
);
|
|
195
|
+
} else {
|
|
196
|
+
// Explicit missing policy is evaluated per measure. Validate every
|
|
197
|
+
// nonmissing value now, while leaving nullish value/weight pairs for the
|
|
198
|
+
// reason-priority accounting below.
|
|
199
|
+
readStatisticalWeights(
|
|
200
|
+
rows.filter(row => row[transform.weight.field] !== null && row[transform.weight.field] !== undefined),
|
|
201
|
+
transform.weight,
|
|
202
|
+
"Summary"
|
|
203
|
+
);
|
|
204
|
+
for (const aggregate of transform.aggregates) {
|
|
205
|
+
if (aggregate.field === undefined) continue;
|
|
206
|
+
validateAggregateValues(
|
|
207
|
+
rows.filter(row => row[aggregate.field] !== null && row[aggregate.field] !== undefined),
|
|
208
|
+
aggregate
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (transform.missing === "error") {
|
|
213
|
+
if (transform.weight !== undefined) {
|
|
214
|
+
const weightIndex = rows.findIndex(row =>
|
|
215
|
+
row[transform.weight.field] === null || row[transform.weight.field] === undefined
|
|
216
|
+
);
|
|
217
|
+
if (weightIndex !== -1) {
|
|
218
|
+
throw new TypeError(`Summary weight "${transform.weight.field}" is missing at row ${weightIndex}.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const aggregate of transform.aggregates) {
|
|
222
|
+
if (aggregate.field === undefined) continue;
|
|
223
|
+
const index = rows.findIndex(row => row[aggregate.field] === null || row[aggregate.field] === undefined);
|
|
224
|
+
if (index !== -1) throw new TypeError(`Summary field "${aggregate.field}" is missing at row ${index}.`);
|
|
225
|
+
}
|
|
186
226
|
}
|
|
187
227
|
|
|
188
228
|
const groups = new Map();
|
|
@@ -212,34 +252,92 @@ export function deriveSummaryRows(rows, transform) {
|
|
|
212
252
|
throw new RangeError(`Summary output cannot exceed ${MAX_OUTPUT_ROWS} groups.`);
|
|
213
253
|
}
|
|
214
254
|
|
|
215
|
-
|
|
216
|
-
|
|
255
|
+
const units = [];
|
|
256
|
+
const values = [...groups.values()].map(group => {
|
|
257
|
+
const sharedWeightSummary = transform.weight === undefined || explicitWeighted
|
|
217
258
|
? undefined
|
|
218
259
|
: summarizeStatisticalWeights(
|
|
219
260
|
group.entries,
|
|
220
261
|
transform.weight.kind,
|
|
221
262
|
"Summary group"
|
|
222
263
|
);
|
|
264
|
+
const aggregated = Object.fromEntries(transform.aggregates.map(aggregate => {
|
|
265
|
+
let missingValue = 0;
|
|
266
|
+
let missingWeight = 0;
|
|
267
|
+
let zeroWeightRows = 0;
|
|
268
|
+
let measureWeightSummary = sharedWeightSummary;
|
|
269
|
+
if (explicitWeighted) {
|
|
270
|
+
const eligible = group.rows.filter(row => {
|
|
271
|
+
if (aggregate.field !== undefined &&
|
|
272
|
+
(row[aggregate.field] === null || row[aggregate.field] === undefined)) {
|
|
273
|
+
missingValue += 1;
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (row[transform.weight.field] === null || row[transform.weight.field] === undefined) {
|
|
277
|
+
missingWeight += 1;
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
return true;
|
|
281
|
+
});
|
|
282
|
+
const entries = readStatisticalWeights(eligible, transform.weight, "Summary").entries;
|
|
283
|
+
zeroWeightRows = entries.filter(entry => entry.weight === 0).length;
|
|
284
|
+
measureWeightSummary = summarizeStatisticalWeights(
|
|
285
|
+
entries,
|
|
286
|
+
transform.weight.kind,
|
|
287
|
+
"Summary group"
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
let value = measureWeightSummary === undefined
|
|
291
|
+
? aggregateRows(group.rows, aggregate.field ?? "__row", aggregate.op)
|
|
292
|
+
: calculateWeightedAggregate(
|
|
293
|
+
measureWeightSummary,
|
|
294
|
+
aggregate.field,
|
|
295
|
+
aggregate.op,
|
|
296
|
+
`Summary aggregate "${aggregate.as}"`
|
|
297
|
+
);
|
|
298
|
+
if (value === undefined && transform.empty !== undefined) {
|
|
299
|
+
const op = typeof aggregate.op === "string" ? aggregate.op : aggregate.op.op;
|
|
300
|
+
value = transform.empty === "identity" && op === "sum" ? 0 : null;
|
|
301
|
+
}
|
|
302
|
+
if (transform.missing !== undefined || transform.empty !== undefined) {
|
|
303
|
+
const missing = explicitWeighted
|
|
304
|
+
? missingValue + missingWeight
|
|
305
|
+
: aggregate.field === undefined ? 0 : group.rows.filter(row => row[aggregate.field] === null || row[aggregate.field] === undefined).length;
|
|
306
|
+
units.push({
|
|
307
|
+
role: aggregate.as,
|
|
308
|
+
group: group.values,
|
|
309
|
+
inputRows: group.rows.length,
|
|
310
|
+
usedRows: group.rows.length - missing,
|
|
311
|
+
excludedRows: missing,
|
|
312
|
+
excludedByReason: missing === 0 ? {} : {
|
|
313
|
+
...(missingValue === 0 && explicitWeighted ? {} : { "missing-value": explicitWeighted ? missingValue : missing }),
|
|
314
|
+
...(missingWeight === 0 ? {} : { "missing-weight": missingWeight })
|
|
315
|
+
},
|
|
316
|
+
...(explicitWeighted ? { zeroWeightRows } : {})
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return [aggregate.as, value];
|
|
320
|
+
}));
|
|
223
321
|
return {
|
|
224
322
|
...group.values,
|
|
225
|
-
...
|
|
226
|
-
aggregate.as,
|
|
227
|
-
weightSummary === undefined
|
|
228
|
-
? aggregateRows(group.rows, aggregate.field ?? "__row", aggregate.op)
|
|
229
|
-
: calculateWeightedAggregate(
|
|
230
|
-
weightSummary,
|
|
231
|
-
aggregate.field,
|
|
232
|
-
aggregate.op,
|
|
233
|
-
`Summary aggregate "${aggregate.as}"`
|
|
234
|
-
)
|
|
235
|
-
])),
|
|
323
|
+
...aggregated,
|
|
236
324
|
...(transform.members === undefined
|
|
237
325
|
? {}
|
|
238
326
|
: {
|
|
239
|
-
[transform.members]:
|
|
327
|
+
[transform.members]: sharedWeightSummary === undefined
|
|
240
328
|
? group.rows
|
|
241
|
-
: weightedRows(
|
|
329
|
+
: weightedRows(sharedWeightSummary)
|
|
242
330
|
})
|
|
243
331
|
};
|
|
244
332
|
});
|
|
333
|
+
if (transform.missing === undefined && transform.empty === undefined) return values;
|
|
334
|
+
return {
|
|
335
|
+
values,
|
|
336
|
+
report: cloneAndFreeze({
|
|
337
|
+
version: 1,
|
|
338
|
+
owner: { kind: "data", id: "pending" },
|
|
339
|
+
inputs: [],
|
|
340
|
+
units
|
|
341
|
+
})
|
|
342
|
+
};
|
|
245
343
|
}
|
|
@@ -16,6 +16,7 @@ const TRANSFORM_TOPOLOGY = Object.freeze({
|
|
|
16
16
|
markFilter: Object.freeze({ provenanceTransparent: true }),
|
|
17
17
|
normalize: Object.freeze({ facetTopology: "statistical" }),
|
|
18
18
|
regression: Object.freeze({ facetTopology: "statistical" }),
|
|
19
|
+
sort: Object.freeze({ facetTopology: "statistical" }),
|
|
19
20
|
summary: Object.freeze({ facetTopology: "statistical" }),
|
|
20
21
|
stack: Object.freeze({ facetTopology: "statistical" }),
|
|
21
22
|
statisticalReference: Object.freeze({ facetTopology: "statistical" }),
|