Skip to content

Commit 850b241

Browse files
fix(db-ivm): treat 0 and empty string as valid min/max extremes (#1809)
* fix(db-ivm): treat 0 and empty string as valid min/max extremes min() and max() used truthiness to detect an unset accumulator, so 0, 0n, and "" were skipped or overwritten. Compare against undefined. Fixes #1775 * fix(db-ivm): narrow min and max inputs * test(db-ivm): cover falsy extrema in oracle --------- Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
1 parent 1ca838b commit 850b241

4 files changed

Lines changed: 253 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db-ivm': patch
3+
---
4+
5+
Compare min and max aggregates against `undefined` instead of truthiness so `0`, `0n`, and `""` can be the extreme of a group.

packages/db-ivm/src/operators/groupBy.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,10 @@ export function min<T, V extends CanMinMax>(
237237
reduce: (values) => {
238238
let minValue: V | undefined
239239
for (const [value, _multiplicity] of values) {
240-
if (!minValue || (value && value < minValue)) {
240+
if (
241+
value !== undefined &&
242+
(minValue === undefined || value < minValue)
243+
) {
241244
minValue = value
242245
}
243246
}
@@ -267,7 +270,10 @@ export function max<T, V extends CanMinMax>(
267270
reduce: (values) => {
268271
let maxValue: V | undefined
269272
for (const [value, _multiplicity] of values) {
270-
if (!maxValue || (value && value > maxValue)) {
273+
if (
274+
value !== undefined &&
275+
(maxValue === undefined || value > maxValue)
276+
) {
271277
maxValue = value
272278
}
273279
}

packages/db-ivm/tests/incrementalization-law.property.test.ts

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,28 @@ import type { Weighted } from './incrementalization-law.js'
4242
*
4343
* The generated domain uses small JSON tuples with integer weights. Named
4444
* cases force empty batches, duplicate weights, replacements, cancellation,
45-
* presence changes, boundary ties, and a zero-width window. Fault controls
46-
* prove that the checker rejects missing, sign-flipped, and wrong-member output.
45+
* presence changes, falsey group extrema, boundary ties, and a zero-width
46+
* window. Fault controls prove that the checker rejects missing, sign-flipped,
47+
* wrong-member, and truthiness-filtered aggregate output.
48+
*
49+
* Before this repair the groupBy branch observed only sums. Truthiness defects
50+
* in `min` and `max` were therefore outside both its model and its assertions.
51+
* The small direct reducer cases remain readable replay witnesses; this suite
52+
* owns the generated incremental-versus-recompute law.
4753
*/
4854

4955
type Keyed = [number, number]
5056
type JoinOutput = [number, [number, number]]
5157
type OuterJoinOutput = [number, [number | null, number | null]]
52-
type GroupedOutput = [string, { bucket: number; total: number }]
58+
type GroupedOutput = [
59+
string,
60+
{
61+
bucket: number
62+
total: number
63+
minimum: number | undefined
64+
maximum: number | undefined
65+
},
66+
]
5367

5468
const FIXED_SEED = 1741
5569
type GeneratedCampaign = {
@@ -219,14 +233,32 @@ function joinedSums(
219233
return [...sums].map(([key, value]) => [[key, value], 1])
220234
}
221235

222-
function groupedSums(input: Weighted<Keyed>): Weighted<GroupedOutput> {
223-
const groups = new Map<number, number>()
236+
/**
237+
* Group extrema come from every retained defined value. Zero is a value, not
238+
* absence. The model recomputes from plain weighted rows and shares no
239+
* aggregate reducer with production.
240+
*/
241+
function groupedAggregates(input: Weighted<Keyed>): Weighted<GroupedOutput> {
242+
const groups = new Map<
243+
number,
244+
{ total: number; minimum: number; maximum: number }
245+
>()
224246
for (const [[key, value], weight] of keyedIdentity(input)) {
225247
const bucket = key % 2
226-
groups.set(bucket, (groups.get(bucket) ?? 0) + value * weight)
248+
const current = groups.get(bucket)
249+
groups.set(
250+
bucket,
251+
current === undefined
252+
? { total: value * weight, minimum: value, maximum: value }
253+
: {
254+
total: current.total + value * weight,
255+
minimum: Math.min(current.minimum, value),
256+
maximum: Math.max(current.maximum, value),
257+
},
258+
)
227259
}
228-
return [...groups].map(([bucket, total]) => [
229-
[JSON.stringify({ bucket }), { bucket, total }],
260+
return [...groups].map(([bucket, aggregates]) => [
261+
[JSON.stringify({ bucket }), { bucket, ...aggregates }],
230262
1,
231263
])
232264
}
@@ -332,9 +364,11 @@ describe(`DBSP incrementalization laws`, () => {
332364
input.pipe(
333365
groupBy(([key]) => ({ bucket: key % 2 }), {
334366
total: groupByOperators.sum(([, value]) => value),
367+
minimum: groupByOperators.min(([, value]) => value),
368+
maximum: groupByOperators.max(([, value]) => value),
335369
}),
336370
),
337-
evaluate: groupedSums,
371+
evaluate: groupedAggregates,
338372
})
339373
assertUnaryIncrementalization({
340374
name: `top-K`,
@@ -555,6 +589,35 @@ describe(`DBSP incrementalization laws`, () => {
555589
splitDeliveries: 4,
556590
})
557591

592+
const extremaReach = assertUnaryIncrementalization({
593+
name: `grouped falsey extrema`,
594+
initial: [
595+
[[0, 5], 1],
596+
[[2, 0], 1],
597+
[[1, -2], 1],
598+
[[3, 0], 1],
599+
],
600+
batches: [],
601+
inputPolicy: keyedPolicy,
602+
outputPolicy: groupedPolicy,
603+
splitDomain: uniqueRowSplitDomain,
604+
build: (input) =>
605+
input.pipe(
606+
groupBy(([key]) => ({ bucket: key % 2 }), {
607+
total: groupByOperators.sum(([, value]) => value),
608+
minimum: groupByOperators.min(([, value]) => value),
609+
maximum: groupByOperators.max(([, value]) => value),
610+
}),
611+
),
612+
evaluate: groupedAggregates,
613+
})
614+
expect(extremaReach).toEqual({
615+
atomicCheckpoints: 1,
616+
atomicDeliveries: 1,
617+
splitCheckpoints: 1,
618+
splitDeliveries: 4,
619+
})
620+
558621
assertUnaryIncrementalization({
559622
name: `grouped-order boundary tie`,
560623
initial: [
@@ -617,7 +680,7 @@ describe(`DBSP incrementalization laws`, () => {
617680
})
618681
})
619682

620-
it(`rejects omitted, sign-flipped, wrong-member, and wrong-window output`, () => {
683+
it(`rejects omitted, sign-flipped, wrong-member, wrong-window, and truthiness-filtered output`, () => {
621684
expect(() =>
622685
assertUnaryIncrementalization({
623686
name: `omitted output fault`,
@@ -674,5 +737,56 @@ describe(`DBSP incrementalization laws`, () => {
674737
evaluate: firstThree,
675738
}),
676739
).toThrow(/output delta diverged/)
740+
741+
const truthinessMinimum = {
742+
preMap: ([, value]: Keyed): number | undefined => value,
743+
reduce: (values: Array<[number | undefined, number]>) => {
744+
let minimum: number | undefined
745+
for (const [value] of values) {
746+
if (!minimum || (value !== undefined && value && value < minimum)) {
747+
minimum = value
748+
}
749+
}
750+
return minimum
751+
},
752+
postMap: (result: number | undefined) => result,
753+
}
754+
const truthinessMaximum = {
755+
preMap: ([, value]: Keyed): number | undefined => value,
756+
reduce: (values: Array<[number | undefined, number]>) => {
757+
let maximum: number | undefined
758+
for (const [value] of values) {
759+
if (!maximum || (value !== undefined && value && value > maximum)) {
760+
maximum = value
761+
}
762+
}
763+
return maximum
764+
},
765+
postMap: (result: number | undefined) => result,
766+
}
767+
expect(() =>
768+
assertUnaryIncrementalization({
769+
name: `truthiness-filtered extrema fault`,
770+
initial: [
771+
[[0, 5], 1],
772+
[[2, 0], 1],
773+
[[1, -2], 1],
774+
[[3, 0], 1],
775+
],
776+
batches: [],
777+
inputPolicy: keyedPolicy,
778+
outputPolicy: groupedPolicy,
779+
splitDomain: uniqueRowSplitDomain,
780+
build: (input) =>
781+
input.pipe(
782+
groupBy(([key]) => ({ bucket: key % 2 }), {
783+
total: groupByOperators.sum(([, value]) => value),
784+
minimum: truthinessMinimum,
785+
maximum: truthinessMaximum,
786+
}),
787+
),
788+
evaluate: groupedAggregates,
789+
}),
790+
).toThrow(/output delta diverged/)
677791
})
678792
})

packages/db-ivm/tests/operators/groupBy.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,122 @@ describe(`Operators`, () => {
624624
expect(latestMessage.getInner()).toEqual(expectedResult)
625625
})
626626

627+
// These are readable replay witnesses. The generated groupBy law lives in
628+
// incrementalization-law.property.test.ts.
629+
test(`min and max reduce keep 0, 0n, and empty string as extremes`, () => {
630+
const minNum = min<number>()
631+
const maxNum = max<number>()
632+
const minStr = min<string>()
633+
const minBig = min<bigint>()
634+
const maxBig = max<bigint>()
635+
636+
if (
637+
!(`reduce` in minNum) ||
638+
!(`reduce` in maxNum) ||
639+
!(`reduce` in minStr) ||
640+
!(`reduce` in minBig) ||
641+
!(`reduce` in maxBig)
642+
) {
643+
throw new Error(`Expected direct min/max aggregates`)
644+
}
645+
646+
expect(
647+
minNum.reduce([
648+
[undefined, 1],
649+
[5, 1],
650+
[0, 1],
651+
]),
652+
).toBe(0)
653+
expect(
654+
minNum.reduce([
655+
[0, 1],
656+
[3, 1],
657+
]),
658+
).toBe(0)
659+
expect(
660+
maxNum.reduce([
661+
[undefined, 1],
662+
[-2, 1],
663+
[0, 1],
664+
[-1, 1],
665+
]),
666+
).toBe(0)
667+
expect(
668+
maxNum.reduce([
669+
[0, 1],
670+
[-1, 1],
671+
]),
672+
).toBe(0)
673+
expect(
674+
minStr.reduce([
675+
[`b`, 1],
676+
[``, 1],
677+
]),
678+
).toBe(``)
679+
expect(
680+
minStr.reduce([
681+
[``, 1],
682+
[`a`, 1],
683+
]),
684+
).toBe(``)
685+
expect(
686+
minBig.reduce([
687+
[5n, 1],
688+
[0n, 1],
689+
]),
690+
).toBe(0n)
691+
expect(
692+
maxBig.reduce([
693+
[-2n, 1],
694+
[0n, 1],
695+
]),
696+
).toBe(0n)
697+
})
698+
699+
test(`with min and max aggregates including a zero amount`, () => {
700+
const graph = new D2()
701+
const input = graph.newInput<{
702+
category: string
703+
amount: number
704+
}>()
705+
let latestMessage: any = null
706+
707+
input.pipe(
708+
groupBy((data) => ({ category: data.category }), {
709+
minimum: min((data) => data.amount),
710+
maximum: max((data) => data.amount),
711+
}),
712+
output((message) => {
713+
latestMessage = message
714+
}),
715+
)
716+
717+
graph.finalize()
718+
719+
input.sendData(
720+
new MultiSet([
721+
[{ category: `A`, amount: 10 }, 1],
722+
[{ category: `A`, amount: 0 }, 1],
723+
[{ category: `A`, amount: 7 }, 1],
724+
]),
725+
)
726+
graph.run()
727+
728+
expect(latestMessage.getInner()).toEqual([
729+
[
730+
[
731+
serializeValue({ category: `A` }),
732+
{
733+
category: `A`,
734+
minimum: 0,
735+
maximum: 10,
736+
},
737+
],
738+
1,
739+
],
740+
])
741+
})
742+
627743
test(`with median and mode aggregates`, () => {
628744
const graph = new D2()
629745
const input = graph.newInput<{

0 commit comments

Comments
 (0)