Skip to content

Commit abe968e

Browse files
committed
SY-4149: preserve bigint channel precision through the float32 GL render pipeline
values above 2^53 (timestamps, int64, uint64) were quantizing to multiples of the float32 ULP at display time; route bigint offsets through bigint arithmetic and preserve bigint precision in stringifyNumber's standard branch
1 parent c2d138b commit abe968e

12 files changed

Lines changed: 348 additions & 27 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2026 Synnax Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License included in the file
4+
// licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with the Business Source
7+
// License, use of this software will be governed by the Apache License, Version 2.0,
8+
// included in the file licenses/APL.txt.
9+
10+
import { DataType, Series } from "@synnaxlabs/x";
11+
import { describe, expect, it } from "vitest";
12+
13+
import {
14+
convertSeriesToSupportedGL,
15+
resolveGLDataType,
16+
} from "@/telem/aether/convertSeries";
17+
18+
describe("convertSeriesToSupportedGL", () => {
19+
it("returns the original series for variable data types", () => {
20+
const series = new Series({
21+
data: ["a", "b", "c"],
22+
dataType: DataType.STRING,
23+
});
24+
expect(convertSeriesToSupportedGL(series)).toBe(series);
25+
});
26+
27+
it("returns the original series for UINT8", () => {
28+
const series = new Series({
29+
data: new Uint8Array([1, 2, 3]),
30+
dataType: DataType.UINT8,
31+
});
32+
expect(convertSeriesToSupportedGL(series)).toBe(series);
33+
});
34+
35+
it("converts a FLOAT64 series to FLOAT32 with no offset", () => {
36+
const series = new Series({
37+
data: new Float64Array([1.5, 2.5, 3.5]),
38+
dataType: DataType.FLOAT64,
39+
});
40+
const result = convertSeriesToSupportedGL(series);
41+
expect(result.dataType.equals(DataType.FLOAT32)).toBe(true);
42+
expect(result.at(0)).toBe(1.5);
43+
expect(result.at(1)).toBe(2.5);
44+
expect(result.at(2)).toBe(3.5);
45+
});
46+
47+
it("defaults the offset to the first sample for INT64 to preserve precision above 2^53", () => {
48+
const first = 1778020940471336960n;
49+
const series = new Series({
50+
data: [first, first + 1n, first + 2n],
51+
dataType: DataType.INT64,
52+
});
53+
const result = convertSeriesToSupportedGL(series);
54+
expect(result.dataType.equals(DataType.FLOAT32)).toBe(true);
55+
expect(result.sampleOffset).toBe(first);
56+
expect(result.at(0)).toBe(first);
57+
expect(result.at(1)).toBe(first + 1n);
58+
expect(result.at(2)).toBe(first + 2n);
59+
});
60+
61+
it("defaults the offset to the first sample for UINT64 to preserve precision above 2^53", () => {
62+
const first = 1778020940471336960n;
63+
const series = new Series({
64+
data: [first, first + 1n, first + 2n],
65+
dataType: DataType.UINT64,
66+
});
67+
const result = convertSeriesToSupportedGL(series);
68+
expect(result.dataType.equals(DataType.FLOAT32)).toBe(true);
69+
expect(result.sampleOffset).toBe(first);
70+
expect(result.at(0)).toBe(first);
71+
});
72+
73+
it("defaults the offset to the first sample for TIMESTAMP to preserve precision above 2^53", () => {
74+
const first = 1778020940471336960n;
75+
const series = new Series({
76+
data: [first, first + 1n, first + 2n],
77+
dataType: DataType.TIMESTAMP,
78+
});
79+
const result = convertSeriesToSupportedGL(series);
80+
expect(result.dataType.equals(DataType.FLOAT32)).toBe(true);
81+
expect(result.sampleOffset).toBe(first);
82+
expect(result.at(0)).toBe(first);
83+
});
84+
85+
it("respects an explicitly provided offset for bigint series", () => {
86+
const offset = 1778020940471336000n;
87+
const first = 1778020940471336960n;
88+
const series = new Series({
89+
data: [first, first + 1n, first + 2n],
90+
dataType: DataType.INT64,
91+
});
92+
const result = convertSeriesToSupportedGL(series, offset);
93+
expect(result.sampleOffset).toBe(offset);
94+
});
95+
});
96+
97+
describe("resolveGLDataType", () => {
98+
it("returns variable data types unchanged", () => {
99+
expect(resolveGLDataType(DataType.STRING).equals(DataType.STRING)).toBe(true);
100+
expect(resolveGLDataType(DataType.JSON).equals(DataType.JSON)).toBe(true);
101+
});
102+
103+
it("returns UINT8 unchanged", () => {
104+
expect(resolveGLDataType(DataType.UINT8).equals(DataType.UINT8)).toBe(true);
105+
});
106+
107+
it("returns FLOAT32 for any other fixed-density data type", () => {
108+
expect(resolveGLDataType(DataType.FLOAT64).equals(DataType.FLOAT32)).toBe(true);
109+
expect(resolveGLDataType(DataType.INT64).equals(DataType.FLOAT32)).toBe(true);
110+
expect(resolveGLDataType(DataType.UINT64).equals(DataType.FLOAT32)).toBe(true);
111+
expect(resolveGLDataType(DataType.TIMESTAMP).equals(DataType.FLOAT32)).toBe(true);
112+
expect(resolveGLDataType(DataType.INT32).equals(DataType.FLOAT32)).toBe(true);
113+
});
114+
});

pluto/src/telem/aether/convertSeries.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import { DataType, type math, type Series } from "@synnaxlabs/x";
1616
* offset applied.
1717
*
1818
* @param series - The series to convert.
19-
* @param offset - An optional offset to apply to the series. If the series is a timestamp
20-
* series, the default offset is applied to the first value in the series. This helps fix
21-
* issues with reducing precision from uint64s to float32s at high nanosecond values.
19+
* @param offset - An optional offset to apply to the series. If the series uses bigint
20+
* storage (timestamp, int64, uint64) and no offset is provided, the first sample is used
21+
* as the default offset. This preserves precision when narrowing 64-bit integers to
22+
* float32, which would otherwise quantize values above 2^53 to multiples of the float32
23+
* ULP at that magnitude.
2224
* @returns The converted series.
2325
*/
2426
export const convertSeriesToSupportedGL = (
@@ -27,8 +29,7 @@ export const convertSeriesToSupportedGL = (
2729
): Series => {
2830
if (series.dataType.isVariable || series.dataType.equals(DataType.UINT8))
2931
return series;
30-
if (offset == null && series.dataType.equals(DataType.TIMESTAMP))
31-
offset = BigInt(series.data[0]);
32+
if (offset == null && series.dataType.usesBigInt) offset = BigInt(series.data[0]);
3233
return series.convert(DataType.FLOAT32, offset);
3334
};
3435

pluto/src/telem/aether/transformers.spec.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
import { DataType, MultiSeries, Series, TimeRange } from "@synnaxlabs/x";
1111
import { describe, expect, it } from "vitest";
1212

13-
import { SeriesDownsampler } from "@/telem/aether/transformers";
13+
import { TestSource } from "@/telem/aether/test/source";
14+
import {
15+
RollingAverage,
16+
ScaleNumber,
17+
SeriesDownsampler,
18+
StringifyNumber,
19+
} from "@/telem/aether/transformers";
1420

1521
describe("SeriesDownsampler", () => {
1622
describe("decimate mode", () => {
@@ -443,3 +449,63 @@ describe("SeriesDownsampler", () => {
443449
});
444450
});
445451
});
452+
453+
describe("StringifyNumber", () => {
454+
it("formats a number value with prefix and suffix", () => {
455+
const t = new StringifyNumber({ precision: 2, prefix: "$", suffix: " USD" });
456+
t.setSources({ in: new TestSource(42) });
457+
expect(t.value()).toBe("$42.00 USD");
458+
});
459+
460+
it("preserves full precision for a bigint value above 2^53 in standard notation", () => {
461+
const t = new StringifyNumber({ precision: 0, notation: "standard" });
462+
t.setSources({ in: new TestSource(1778020940471336960n) });
463+
expect(t.value()).toBe("1778020940471336960");
464+
});
465+
466+
it("does not crash and returns a finite string for a bigint value", () => {
467+
const t = new StringifyNumber({ precision: 2, notation: "scientific" });
468+
t.setSources({ in: new TestSource(1778020940471336960n) });
469+
expect(t.value()).toBe("1.78ᴇ18");
470+
});
471+
472+
it("returns an empty string for NaN", () => {
473+
const t = new StringifyNumber({ precision: 2 });
474+
t.setSources({ in: new TestSource(NaN) });
475+
expect(t.value()).toBe("");
476+
});
477+
});
478+
479+
describe("RollingAverage", () => {
480+
it("returns the value unchanged when windowSize is less than 2", () => {
481+
const t = new RollingAverage({ windowSize: 1 });
482+
t.setSources({ in: new TestSource(42) });
483+
expect(t.value()).toBe(42);
484+
});
485+
486+
it("coerces a bigint value to a number", () => {
487+
const t = new RollingAverage({ windowSize: 1 });
488+
t.setSources({ in: new TestSource(123n) });
489+
expect(t.value()).toBe(123);
490+
});
491+
});
492+
493+
describe("ScaleNumber", () => {
494+
it("applies the scale and offset to a number value", () => {
495+
const t = new ScaleNumber({ scale: { scale: 2, offset: 3 } });
496+
t.setSources({ in: new TestSource(10) });
497+
expect(t.value()).toBe(23);
498+
});
499+
500+
it("coerces a bigint value to a number before scaling", () => {
501+
const t = new ScaleNumber({ scale: { scale: 2, offset: 0 } });
502+
t.setSources({ in: new TestSource(50n) });
503+
expect(t.value()).toBe(100);
504+
});
505+
506+
it("returns NaN when given NaN", () => {
507+
const t = new ScaleNumber({ scale: { scale: 2, offset: 3 } });
508+
t.setSources({ in: new TestSource(NaN) });
509+
expect(Number.isNaN(t.value())).toBe(true);
510+
});
511+
});

pluto/src/telem/aether/transformers.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
bounds,
1313
color,
1414
id,
15+
type math,
1516
MultiSeries,
1617
notation,
1718
scale,
@@ -177,16 +178,16 @@ export const stringifyNumberProps = z.object({
177178
});
178179

179180
export class StringifyNumber extends UnarySourceTransformer<
180-
number,
181+
math.Numeric,
181182
string,
182183
typeof stringifyNumberProps
183184
> {
184185
static readonly TYPE = "stringify-number";
185186
static readonly propsZ = stringifyNumberProps;
186187
schema = StringifyNumber.propsZ;
187188

188-
protected transform(value: number): string {
189-
if (isNaN(value)) return "";
189+
protected transform(value: math.Numeric): string {
190+
if (typeof value === "number" && isNaN(value)) return "";
190191
const { precision, prefix, suffix, notation: pNotation } = this.props;
191192
return `${prefix}${notation.stringifyNumber(value, precision, pNotation)}${suffix}`;
192193
}
@@ -206,7 +207,7 @@ export const rollingAverageProps = z.object({
206207
});
207208

208209
export class RollingAverage extends UnarySourceTransformer<
209-
number,
210+
math.Numeric,
210211
number,
211212
typeof rollingAverageProps
212213
> {
@@ -215,15 +216,16 @@ export class RollingAverage extends UnarySourceTransformer<
215216
schema = rollingAverageProps;
216217
private values: number[] = [];
217218

218-
protected transform(value: number): number {
219-
if (this.props.windowSize < 2 || isNaN(value)) return value;
219+
protected transform(value: math.Numeric): number {
220+
const num = Number(value);
221+
if (this.props.windowSize < 2 || isNaN(num)) return num;
220222
return this.values.reduce((a, b) => a + b, 0) / this.values.length;
221223
}
222224

223-
protected shouldNotify(value: number): boolean {
225+
protected shouldNotify(value: math.Numeric): boolean {
224226
if (this.props.windowSize < 2) return true;
225227
if (this.values.length > this.props.windowSize) this.values = [];
226-
this.values.push(value);
228+
this.values.push(Number(value));
227229
return this.values.length === this.props.windowSize;
228230
}
229231
}
@@ -269,18 +271,19 @@ export const scaleNumberProps = z.object({
269271
});
270272

271273
export class ScaleNumber extends UnarySourceTransformer<
272-
number,
274+
math.Numeric,
273275
number,
274276
typeof scaleNumberProps
275277
> {
276278
static readonly TYPE = "scale-number";
277279
static readonly propsZ = scaleNumberProps;
278280
schema = ScaleNumber.propsZ;
279281

280-
protected transform(value: number): number {
281-
if (isNaN(value)) return value;
282+
protected transform(value: math.Numeric): number {
283+
const num = Number(value);
284+
if (isNaN(num)) return num;
282285
const { offset, scale } = this.props.scale;
283-
return value * scale + offset;
286+
return num * scale + offset;
284287
}
285288
}
286289

pluto/src/telem/client/cache/dynamic.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,35 @@ describe("DynamicCache", () => {
146146
expect(flushed.series[0]).toBe(allocated.series[0]);
147147
expect(allocated).toHaveLength(6);
148148
});
149+
it("should allocate the buffer with a bigint sampleOffset for bigint data types", () => {
150+
const nowTs = TimeStamp.seconds(1);
151+
const cache = new Dynamic({
152+
dynamicBufferSize: 100,
153+
dataType: DataType.INT64,
154+
now: () => nowTs,
155+
});
156+
const ser = new Series({
157+
data: [nowTs.valueOf(), nowTs.valueOf() + 1n, nowTs.valueOf() + 2n],
158+
dataType: DataType.INT64,
159+
});
160+
const { allocated } = cache.write(new MultiSeries([ser]));
161+
expect(allocated.series).toHaveLength(1);
162+
expect(allocated.series[0].sampleOffset).toBe(nowTs.valueOf());
163+
expect(allocated.series[0].dataType.equals(DataType.FLOAT32)).toBe(true);
164+
});
165+
it("should allocate the buffer with a numeric sampleOffset for non-bigint data types", () => {
166+
const cache = new Dynamic({
167+
dynamicBufferSize: 100,
168+
dataType: DataType.FLOAT32,
169+
});
170+
const ser = new Series({
171+
data: new Float32Array([1, 2, 3]),
172+
dataType: DataType.FLOAT32,
173+
});
174+
const { allocated } = cache.write(new MultiSeries([ser]));
175+
expect(allocated.series).toHaveLength(1);
176+
expect(allocated.series[0].sampleOffset).toBe(0);
177+
});
149178
it("should allocate a buffer properly using a TimeSpan", () => {
150179
let nowF = () => TimeStamp.seconds(1);
151180
const now = () => nowF();

pluto/src/telem/client/cache/dynamic.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
// included in the file licenses/APL.txt.
99

1010
import {
11-
DataType,
11+
type DataType,
1212
math,
1313
MultiSeries,
1414
Series,
@@ -120,12 +120,11 @@ export class Dynamic {
120120
private allocate(capacity: number, alignment: bigint, start: TimeStamp): Series {
121121
this.counter++;
122122
const isVariable = this.props.dataType.isVariable;
123-
const isTimestamp = this.props.dataType.equals(DataType.TIMESTAMP);
124123
return Series.alloc({
125124
capacity: isVariable ? capacity * VARIABLE_DT_MULTIPLIER : capacity,
126125
dataType: resolveGLDataType(this.props.dataType),
127126
timeRange: start.range(TimeStamp.MAX),
128-
sampleOffset: isTimestamp ? start.valueOf() : 0,
127+
sampleOffset: this.props.dataType.usesBigInt ? start.valueOf() : 0,
129128
glBufferUsage: "dynamic",
130129
alignment,
131130
key: `dynamic-${this.counter}`,

0 commit comments

Comments
 (0)