Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ These are the declared lossy remainder, each applying in one named direction.
| --- | --- | --- |
| `drop` | `from`, `direction: "encode"` | states that `from` is deliberately not carried across. Nothing is written, so the path is simply absent from the output |
| `default` | `to`, `value`, `direction`: `"encode"` or `"decode"` | inserts `value` at `to` when running in that direction and `to` is still absent from the output. `to` has no wildcard |
| `aggregate` | `fn`: `count`, `sum`, `min` or `max`; `from`, `to`, `direction` | when running in that direction, reads the elements `from` reaches in the input and writes their count, or the sum, minimum or maximum of their numbers, at `to`. `to` has fewer wildcards than `from`, and the elements are grouped by the indexes at the wildcards `to` keeps, so `orders[].lines[].qty` into `sales[].units` writes one total per order. An empty group counts and sums to 0; `min` and `max` of an empty group throw |

### Gating with `when`

Expand All @@ -243,7 +244,7 @@ The errors crossmap defines all extend `CrossmapError`: `CrosswalkSyntaxError` f

## Round trips

`assertRoundTrip(crosswalk, samples)` encodes and decodes each sample and deep-compares the result with it, leaving out the paths one-way rules touch: a dropped path cannot survive, and a decode-side default inserts a path the sample never had. It throws on the first difference. Its limits follow from the format:
`assertRoundTrip(crosswalk, samples)` encodes and decodes each sample and deep-compares the result with it, leaving out the paths one-way rules touch: a dropped path cannot survive, and a decode-direction default or aggregate inserts a path the sample never had. It throws on the first difference. Its limits follow from the format:

- A crosswalk is a total mapping over the document. A field no rule maps does not survive encode, so a sample carrying one fails its own round trip.
- A `scale` with a non-integer factor can fail bit-identity: multiplying and then dividing is exact for integers and powers of two, and can differ in the last bit for factors such as `0.1`. Decode divides and does not multiply by a reciprocal, which is the more accurate route.
Expand All @@ -261,7 +262,6 @@ Each of these is a deliberate omission, listed so it is not mistaken for an over

- `nest` and `unnest`: a `move` whose `to` is deeper nests, one whose `to` is shallower flattens, and a `move` of a whole record carries every field in it, so a dedicated operation would add nothing the format cannot already say.
- Lookup against external tables.
- Aggregates and folds over arrays.
- Array filter and sort.
- Regex split.
- Further `format` kinds.
Expand Down
16 changes: 15 additions & 1 deletion src/corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const whenQtyIsBig: When = { kind: "compare", op: "gt", left: { kind: "reference
/**
* Samples carry only mapped fields (the crosswalk is a total mapping: an unmapped field does not survive encode, so a sample carrying one would fail its own round trip) and use the canonical ISO form decode emits, since any other ISO spelling names the same instant but not the same string.
*
* The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, template, copy, drop, default encode-direction, default decode-direction), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, a per-element when cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case).
* The deterministic corpus: one cell per op-table row (move, scale, enum, format isoDateTime, format numberText, split, template, copy, drop, default encode-direction, default decode-direction, aggregate), plus a wildcard-arity-one cell, a when-applied cell, a when-skipped cell, a per-element when cell, and one cell per path shape beyond records and one wildcard (nesting and flattening, an array of arrays, fixed indices, quoted keys). Scale factors are integers by design: multiply-then-divide is bit-identical for them (a non-integer factor is a documented round-trip caveat, not a corpus case).
*/
export const CORPUS: readonly CorpusCell[] = [
{
Expand Down Expand Up @@ -114,6 +114,19 @@ export const CORPUS: readonly CorpusCell[] = [
crosswalk: { source: "native", target: "shared", rules: [{ op: "copy", from: "items[].id", to: ["nodes[].id", "labels[].text"] }], oneWay: [] },
samples: [{ items: [{ id: "a" }, { id: { nested: [1] } }] }, { items: [] }],
},
{
name: "aggregate",
crosswalk: {
source: "native",
target: "shared",
rules: [{ op: "move", from: "orders[].lines[].qty", to: "sales[].lines[].qty" }],
oneWay: [
{ op: "aggregate", fn: "sum", from: "orders[].lines[].qty", to: "sales[].units", direction: "encode" },
{ op: "aggregate", fn: "max", from: "sales[].lines[].qty", to: "largestLine", direction: "decode" },
],
},
samples: [{ orders: [{ lines: [{ qty: 1 }, { qty: 2 }] }, { lines: [{ qty: 0 }] }] }],
},
{
name: "when applied",
crosswalk: {
Expand Down Expand Up @@ -210,6 +223,7 @@ export const REQUIRED_ROWS: readonly string[] = [
"drop",
"default encode-direction",
"default decode-direction",
"aggregate",
"wildcard arity one",
"when applied",
"when skipped",
Expand Down
18 changes: 17 additions & 1 deletion src/crosswalk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,24 @@ export const DefaultRuleSchema = z
.strict();
export type DefaultRule = z.infer<typeof DefaultRuleSchema>;

export const AggregateFunctionSchema = z.enum(["count", "sum", "min", "max"]);
export type AggregateFunction = z.infer<typeof AggregateFunctionSchema>;

/** A value derived from the elements a wildcard path reaches, written in one direction: `count` of the matches, or their `sum`, `min` or `max` (numbers). The elements it combines cannot be recovered from it, which is why it is one-way. `to` has fewer wildcards than `from`; with `k` of them, the matches are grouped by their first `k` indexes, one result per group (a total per order, say). */
export const AggregateRuleSchema = z
.object({
op: z.literal("aggregate"),
fn: AggregateFunctionSchema,
from: PathField,
to: PathField,
direction: z.enum(["encode", "decode"]),
when: PredicateNodeSchema.optional(),
})
.strict();
export type AggregateRule = z.infer<typeof AggregateRuleSchema>;

/** A one-way rule: explicitly lossy, applying in one direction only. */
export const OneWayRuleSchema = z.discriminatedUnion("op", [DropRuleSchema, DefaultRuleSchema]);
export const OneWayRuleSchema = z.discriminatedUnion("op", [DropRuleSchema, DefaultRuleSchema, AggregateRuleSchema]);
export type OneWayRule = z.infer<typeof OneWayRuleSchema>;

/**
Expand Down
4 changes: 2 additions & 2 deletions src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ function run(crosswalk: Crosswalk, doc: unknown, direction: "encode" | "decode")
return;
}
try {
applyOneWayRule(rule, direction, target);
applyOneWayRule(rule, direction, doc, target);
} catch (error) {
throw new RuleApplicationError(crosswalk.rules.length + index, rule.op === "drop" ? rule.from : rule.to, errorMessage(error));
throw new RuleApplicationError(crosswalk.rules.length + index, rule.op === "default" ? rule.to : rule.from, errorMessage(error));
}
});
const hole = findHole(target);
Expand Down
36 changes: 36 additions & 0 deletions src/executor.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { type Crosswalk, parseCrosswalk } from "./crosswalk";
import { CrosswalkStructureError, RuleApplicationError } from "./errors";
import { decode, encode } from "./executor";
import { assertRoundTrip } from "./roundtrip";

const crosswalk = (doc: unknown): Crosswalk => parseCrosswalk(doc);

Expand Down Expand Up @@ -339,3 +340,38 @@ describe("template and copy", () => {
expect(() => decode(cw, { nodes: [{ id: "a" }], labels: [] })).toThrow(/addresses different elements/);
});
});

describe("aggregate", () => {
it("adds derived totals on encode and leaves them out of decode", () => {
const cw = crosswalk({
source: "n",
target: "s",
rules: [{ op: "move", from: "orders[].lines[].qty", to: "sales[].lines[].qty" }],
oneWay: [
{ op: "aggregate", fn: "sum", from: "orders[].lines[].qty", to: "sales[].units", direction: "encode" },
{ op: "aggregate", fn: "count", from: "orders[]", to: "orderCount", direction: "encode" },
],
});
const native = { orders: [{ lines: [{ qty: 1 }, { qty: 2 }] }, { lines: [] }] };
const shared = encode(cw, native);
expect(shared).toEqual({ sales: [{ lines: [{ qty: 1 }, { qty: 2 }], units: 3 }, { lines: [], units: 0 }], orderCount: 2 });
expect(decode(cw, shared)).toEqual(native);
assertRoundTrip(cw, [native]);
});

it("writes a decode-direction aggregate into the source shape, excluded from the round trip", () => {
const cw = crosswalk({
source: "n",
target: "s",
rules: [{ op: "move", from: "tags[]", to: "labels[]" }],
oneWay: [{ op: "aggregate", fn: "count", from: "labels[]", to: "tagCount", direction: "decode" }],
});
expect(decode(cw, { labels: ["a", "b"] })).toEqual({ tags: ["a", "b"], tagCount: 2 });
assertRoundTrip(cw, [{ tags: ["a"] }]);
});

it("throws RuleApplicationError for a value that does not fit", () => {
const cw = crosswalk({ source: "n", target: "s", rules: [], oneWay: [{ op: "aggregate", fn: "sum", from: "xs[]", to: "total", direction: "encode" }] });
expect(() => encode(cw, { xs: ["1"] })).toThrow(RuleApplicationError);
});
});
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { CrosswalkSchema, FormatKindSchema, parseCrosswalk } from "./crosswalk";
export { AggregateFunctionSchema, CrosswalkSchema, FormatKindSchema, parseCrosswalk } from "./crosswalk";
export type {
AggregateFunction,
AggregateRule,
CopyRule,
Crosswalk,
DefaultRule,
Expand Down
84 changes: 84 additions & 0 deletions src/ops/aggregate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { AggregateFunction, AggregateRule } from "../crosswalk";
import { bindLocus, type Locus, parsePathMemo, readLocus, type Trail, wildcardCount, wildcardIndexes } from "../paths";

/**
* Computes an aggregate rule's results against the governing input document: the matches of `from` grouped by their indexes at the first `k` wildcards, where `k` is the wildcard count of `to`, and one value per group written at `to` bound to those indexes. Every group is enumerated from the arrays themselves, so a group with no elements (an empty inner array) still gets its result.
* @param rule - The rule; validateCrosswalk has checked that `to` has fewer wildcards than `from`.
* @param doc - The governing input document (the source while encoding, the target while decoding).
* @returns One write per group.
* @throws Error when `from` is absent from the document, a value does not fit the function, or `min` or `max` meets a group with no values.
*/
export function aggregateWrites(rule: AggregateRule, doc: unknown): readonly { readonly trail: Trail; readonly value: number }[] {
const from = parsePathMemo(rule.from);
const to = parsePathMemo(rule.to);
const groupDepth = wildcardCount(to);
const reading = readLocus(doc, from);
const groups = readLocus(doc, prefixThroughWildcard(from, groupDepth));
if (reading === undefined || groups === undefined) {
throw new Error("the path is absent from the document");
}
const groupValues = groups.matches.map((group) => ({ indexes: wildcardIndexes(from, group.trail), values: new Array<unknown>() }));
const byKey = new Map(groupValues.map((group) => [group.indexes.join(","), group.values]));
for (const match of reading.matches) {
const key = wildcardIndexes(from, match.trail).slice(0, groupDepth).join(",");
const bucket = byKey.get(key);
// Every match descends from an element of the group array, which the second reading enumerated, so a miss means the two readings disagree: a bug here, not a data condition.
if (bucket === undefined) {
throw new Error(`a match of ${rule.from} belongs to no group at [${key}]`);
}
bucket.push(match.value);
}

return groupValues.map((group) => ({ trail: bindLocus(to, group.indexes), value: combine(rule.fn, group.values) }));
}

/** The prefix of a locus through its n-th wildcard, the array whose elements form the groups; the whole document (the empty prefix) for n of zero. */
function prefixThroughWildcard(locus: Locus, wildcards: number): Locus {
if (wildcards === 0) {
return [];
}
let seen = 0;
const end = locus.findIndex((step) => {
if (step.kind === "wildcard") {
seen += 1;
}

return seen === wildcards;
});

return locus.slice(0, end + 1);
}

/**
* Combines one group's values.
* @param fn - The aggregate function.
* @param values - The group's values in index order.
* @returns The count, or the sum, minimum or maximum of finite numbers.
* @throws Error for a value that is not a finite number (except under `count`, which counts any value), a sum that overflows, or `min` or `max` of no values, which has no answer.
*/
export function combine(fn: AggregateFunction, values: readonly unknown[]): number {
if (fn === "count") {
return values.length;
}
const numbers = values.map((value) => {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${fn} applies to finite numbers, found: ${JSON.stringify(value)}`);
}

return value;
});
if (fn === "sum") {
const total = numbers.reduce((sum, value) => sum + value, 0);
if (!Number.isFinite(total)) {
throw new Error("the sum overflows to a non-finite value");
}

return total;
}
if (numbers.length === 0) {
throw new Error(`${fn} of no values has no answer: gate the rule with when, or use count`);
}

// A reduce rather than Math.min(...numbers): spreading a large group as arguments overflows the call stack.
return numbers.reduce((best, value) => (fn === "min" ? Math.min(best, value) : Math.max(best, value)));
}
53 changes: 53 additions & 0 deletions src/ops/aggregate.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { AggregateFunction, AggregateRule } from "../crosswalk";
import { aggregateWrites, combine } from "./aggregate";

/** Named figures, so the cases assert against names rather than bare literals. */
const figures = { small: 2, large: 7, total: 9, negative: -3, three: 3 };

const cases: readonly (readonly [AggregateFunction, readonly unknown[], number])[] = [
["count", [figures.small, "x", null], figures.three],
["sum", [figures.small, figures.large], figures.total],
["min", [figures.large, figures.negative, figures.small], figures.negative],
["max", [figures.small, figures.large], figures.large],
["count", [], 0],
["sum", [], 0],
];
const emptyless: readonly AggregateFunction[] = ["min", "max"];

describe("combine", () => {
it.each(cases)("%s of %j is %j", (fn, values, expected) => {
expect(combine(fn, values)).toBe(expected);
});

it.each(emptyless)("%s of no values throws", (fn) => {
expect(() => combine(fn, [])).toThrow(/has no answer/);
});

it("refuses a value that is not a finite number", () => {
expect(() => combine("sum", [1, "2"])).toThrow(/finite numbers/);
expect(() => combine("max", [Number.NaN])).toThrow(/finite numbers/);
});

it("refuses a sum that overflows", () => {
expect(() => combine("sum", [Number.MAX_VALUE, Number.MAX_VALUE])).toThrow(/overflows/);
});
});

describe("aggregateWrites", () => {
it("writes one total for the whole array", () => {
const rule: AggregateRule = { op: "aggregate", fn: "count", from: "items[]", to: "itemCount", direction: "encode" };
expect(aggregateWrites(rule, { items: ["a", "b"] })).toEqual([{ trail: [{ kind: "key", key: "itemCount" }], value: 2 }]);
});

it("writes one total per outer element, including elements whose inner array is empty", () => {
const rule: AggregateRule = { op: "aggregate", fn: "sum", from: "orders[].lines[].qty", to: "sales[].units", direction: "encode" };
const doc = { orders: [{ lines: [{ qty: figures.small }, { qty: figures.large }] }, { lines: [] }] };
expect(aggregateWrites(rule, doc).map((write) => write.value)).toEqual([figures.total, 0]);
});

it("throws when the path is absent", () => {
const rule: AggregateRule = { op: "aggregate", fn: "count", from: "items[]", to: "n", direction: "encode" };
expect(() => aggregateWrites(rule, {})).toThrow(/absent/);
});
});
15 changes: 9 additions & 6 deletions src/ops/one-way.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import type { OneWayRule } from "../crosswalk";
import { bindLocus, parsePathMemo, readTrail, writeMatches } from "../paths";
import { aggregateWrites } from "./aggregate";

/**
* Applies a one-way rule to the output document under construction, after every two-way rule has written. `drop` declares that its `from` is deliberately not carried across: in the executor's fresh output a path no rule writes is already absent, so the declaration is what the format and assertRoundTrip's exclusion list consume, and there is nothing to do. `default` inserts its value only when the executor is running the direction it names and its `to` is still absent from the output.
* Applies a one-way rule to the output document under construction, after every two-way rule has written. `aggregate` writes its derived values, read from the input document, when the executor is running the direction it names. `drop` declares that its `from` is deliberately not carried across: in the executor's fresh output a path no rule writes is already absent, so the declaration is what the format and assertRoundTrip's exclusion list consume, and there is nothing to do. `default` inserts its value only when the executor is running the direction it names and its `to` is still absent from the output.
* @param rule - The one-way rule to apply.
* @param direction - The direction the executor is running.
* @param doc - The input document of this run, which an aggregate reads.
* @param target - The output document under construction.
*/
export function applyOneWayRule(
rule: OneWayRule,
direction: "encode" | "decode",
target: Record<string, unknown>,
): void {
export function applyOneWayRule(rule: OneWayRule, direction: "encode" | "decode", doc: unknown, target: Record<string, unknown>): void {
if (rule.op === "drop") {
return;
}
if (rule.direction !== direction) {
return;
}
if (rule.op === "aggregate") {
writeMatches(target, aggregateWrites(rule, doc));

return;
}
// validateCrosswalk rejects a wildcard in a default's `to`, so binding with no indexes yields its one concrete trail (bindLocus throws if a wildcard were there).
const trail = bindLocus(parsePathMemo(rule.to), []);
if (readTrail(target, trail).found) {
Expand Down
Loading