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.
Files changed (83) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -1
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/package.json +8 -2
  7. package/src/actions/data/bin.js +1 -1
  8. package/src/actions/data/create.js +18 -5
  9. package/src/actions/data/density.js +1 -1
  10. package/src/actions/data/derived.js +15 -1
  11. package/src/actions/data/edit.js +19 -6
  12. package/src/actions/data/filter.js +15 -4
  13. package/src/actions/data/index.js +3 -0
  14. package/src/actions/data/interval.js +2 -0
  15. package/src/actions/data/regression.js +4 -1
  16. package/src/actions/data/revise.js +37 -5
  17. package/src/actions/data/shared.js +30 -2
  18. package/src/actions/data/sort.js +21 -0
  19. package/src/actions/data/summary.js +1 -1
  20. package/src/actions/encodings/angle.js +4 -1
  21. package/src/actions/encodings/appearance.js +6 -2
  22. package/src/actions/encodings/color/continuous.js +4 -1
  23. package/src/actions/encodings/color/index.js +4 -1
  24. package/src/actions/encodings/offset.js +4 -1
  25. package/src/actions/encodings/position/resolve.js +6 -1
  26. package/src/actions/encodings/ranged.js +6 -1
  27. package/src/actions/encodings/ruleAppearance.js +4 -1
  28. package/src/actions/encodings/stroke.js +4 -1
  29. package/src/actions/encodings/strokeDash.js +6 -1
  30. package/src/actions/encodings/text.js +6 -1
  31. package/src/actions/marks/bar/create.js +8 -1
  32. package/src/actions/marks/bar/edit.js +12 -5
  33. package/src/actions/marks/point/create.js +8 -1
  34. package/src/actions/marks/point/edit.js +12 -5
  35. package/src/actions/marks/point/materialize.js +4 -2
  36. package/src/actions/marks/rect/actions.js +23 -8
  37. package/src/actions/marks/rule/actions.js +24 -8
  38. package/src/actions/marks/text/actions.js +25 -6
  39. package/src/actions/marks/tick/actions.js +23 -8
  40. package/src/actions/primitives/semanticAction.js +37 -3
  41. package/src/actions/primitives/semanticValidation/dataset.js +13 -1
  42. package/src/actions/primitives/semanticValidation/layer.js +22 -2
  43. package/src/actions/primitives/semanticValidation/scale.js +6 -0
  44. package/src/actions/regression/create.js +25 -4
  45. package/src/actions/regression/edit.js +47 -12
  46. package/src/actions/regression/follow.js +22 -0
  47. package/src/actions/scales/channels.js +11 -11
  48. package/src/actions/scales/consumers/common.js +2 -1
  49. package/src/actions/scales/create.js +2 -1
  50. package/src/actions/scales/definitions.js +1 -1
  51. package/src/actions/scales/edit.js +1 -1
  52. package/src/actions/scales/parallel.js +1 -1
  53. package/src/actions/scales/patch.js +1 -1
  54. package/src/actions/scales/preview.js +28 -1
  55. package/src/actions/theme/reconcile.js +2 -0
  56. package/src/core/action.js +3 -2
  57. package/src/core/programState.js +5 -1
  58. package/src/grammar/bin.js +39 -10
  59. package/src/grammar/datasetSchema.js +386 -0
  60. package/src/grammar/density.js +53 -5
  61. package/src/grammar/filter.js +133 -39
  62. package/src/grammar/interval.js +32 -1
  63. package/src/grammar/itemMissing.js +59 -0
  64. package/src/grammar/regression/derive.js +57 -16
  65. package/src/grammar/regression/index.js +1 -0
  66. package/src/grammar/regression/models.js +51 -22
  67. package/src/grammar/regression/parameters.js +56 -4
  68. package/src/grammar/scales/definition.js +7 -0
  69. package/src/grammar/schemas/semanticPath.js +3 -2
  70. package/src/grammar/sort.js +88 -0
  71. package/src/grammar/summary.js +117 -19
  72. package/src/grammar/transformTopology.js +1 -0
  73. package/src/grammar/transforms.js +21 -11
  74. package/src/inspection.js +495 -0
  75. package/src/inspectionDescriptors.js +3 -0
  76. package/src/materialization/bars/resolve.js +4 -2
  77. package/src/materialization/revisionIdentity.js +19 -0
  78. package/src/materialization/scales/resolve.js +67 -4
  79. package/src/materialization/text.js +4 -2
  80. package/src/persistence.js +79 -6
  81. package/src/version.js +1 -1
  82. package/types/inspection.d.ts +17 -0
  83. 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
- residualSumSquares,
321
- residualStandardError: Math.sqrt(residualVariance),
322
- critical: confidenceCriticalValue({
323
- method: confidenceMethod,
324
- level,
325
- degreesOfFreedom
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
- leverage: dot(basis, model.inverse.map(row => dot(row, basis)))
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.fits.find(fit => fit.x === xValue).prediction;
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
+ }
@@ -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
- return [...groups.values()].map(group => {
216
- const weightSummary = transform.weight === undefined
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
- ...Object.fromEntries(transform.aggregates.map(aggregate => [
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]: weightSummary === undefined
327
+ [transform.members]: sharedWeightSummary === undefined
240
328
  ? group.rows
241
- : weightedRows(weightSummary)
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" }),