diff --git a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx
index 408cc73c8..84e0c013f 100644
--- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx
+++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx
@@ -11,7 +11,7 @@ import {
} from '@quent/hooks';
import { DataText } from '../ui/data-text';
import { thinScrollbarClass } from '../ui/thin-scroll';
-import { inferFieldFormatter } from '@quent/utils';
+import { inferFieldFormatter, isNumericValue } from '@quent/utils';
import { DataFlowMatrix } from './DataFlowMatrix';
export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => {
@@ -86,7 +86,7 @@ export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => {
{value.map((item, i) => (
- {item}
+ {String(item)}
))}
@@ -95,7 +95,7 @@ export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => {
{key.replace(/_/g, ' ')}:
- {typeof value === 'number' ? inferFieldFormatter(key)(value) : String(value)}
+ {isNumericValue(value) ? inferFieldFormatter(key)(value) : String(value)}
)}
diff --git a/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts b/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts
index 533468781..5efd81047 100644
--- a/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts
+++ b/ui/packages/@quent/components/src/lib/queryBundle.utils.test.ts
@@ -95,11 +95,6 @@ describe('parseCustomStatistics', () => {
expect(parseCustomStatistics(op)).toEqual([{ key: 'label', value: 'hello' }]);
});
- it('unwraps a boolean tagged value', () => {
- const op = makeOperator({ enabled: makeTagged('Bool', true) });
- expect(parseCustomStatistics(op)).toEqual([{ key: 'enabled', value: true }]);
- });
-
it('produces null for a null-valued tagged entry', () => {
const op = makeOperator({ missing: null });
expect(parseCustomStatistics(op)).toEqual([{ key: 'missing', value: null }]);
diff --git a/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts b/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts
index e0ac317bf..7a29806d1 100644
--- a/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts
+++ b/ui/packages/@quent/components/src/pivot-table/buildPivotedRows.test.ts
@@ -9,7 +9,7 @@ function expanded(
groups: Record,
itemId: string,
statisticName: string,
- value: number | string | null
+ value: number | bigint | string | null
): StatGroupExpandedRow {
const normalized: Record = {};
for (const [k, v] of Object.entries(groups)) {
@@ -156,6 +156,21 @@ describe('buildPivotedRows row clustering', () => {
expect(fordHybrid?.aggs.get('price')?.count).toBe(2);
});
+ it('aggregates bigint stat values (large U64/I64) without throwing', () => {
+ const rows: StatGroupExpandedRow[] = [
+ expanded({ brand: { id: 'Ford' }, fuel: { id: 'Hybrid' } }, 'car-1', 'output_rows', 1000n),
+ expanded({ brand: { id: 'Ford' }, fuel: { id: 'Hybrid' } }, 'car-2', 'output_rows', 3000n),
+ ];
+ const out = buildPivotedRows(rows, [brandIdx, fuelIdx], true);
+ const agg = out[0]?.aggs.get('output_rows');
+ expect(agg?.sum).toBe(4000);
+ expect(agg?.mean).toBe(2000);
+ expect(agg?.min).toBe(1000);
+ expect(agg?.max).toBe(3000);
+ expect(agg?.count).toBe(2);
+ expect(agg?.isNumeric).toBe(true);
+ });
+
it('preserves first-appearance order across both columns of the hierarchy', () => {
// `buildPivotedRows` merges rows that share a full group-key path, so
// duplicate (brand, fuel) pairs collapse to one output row. What we
diff --git a/ui/packages/@quent/components/src/pivot-table/utils.test.ts b/ui/packages/@quent/components/src/pivot-table/utils.test.ts
index 2cd8d2901..eb3171488 100644
--- a/ui/packages/@quent/components/src/pivot-table/utils.test.ts
+++ b/ui/packages/@quent/components/src/pivot-table/utils.test.ts
@@ -161,6 +161,11 @@ describe('formatStatValue', () => {
it('converts a string value using String()', () => {
expect(formatStatValue('hello', 'label')).toBe('hello');
});
+
+ it('formats a bigint value (large U64/I64 stat) via the field formatter', () => {
+ expect(formatStatValue(1073741824n, 'spill_bytes')).toBe('1.00 GiB');
+ expect(formatStatValue(1500n, 'output_rows')).toBe('1.50 k');
+ });
});
// ---- isNumericValue --------------------------------------------------------
@@ -170,6 +175,10 @@ describe('isNumericValue', () => {
expect(isNumericValue(42)).toBe(true);
});
+ it('returns true for a bigint', () => {
+ expect(isNumericValue(42n)).toBe(true);
+ });
+
it('returns false for null', () => {
expect(isNumericValue(null)).toBe(false);
});
diff --git a/ui/packages/@quent/components/src/pivot-table/utils.ts b/ui/packages/@quent/components/src/pivot-table/utils.ts
index f7411c7b4..361d18374 100644
--- a/ui/packages/@quent/components/src/pivot-table/utils.ts
+++ b/ui/packages/@quent/components/src/pivot-table/utils.ts
@@ -1,9 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-import { inferFieldFormatter } from '@quent/utils';
+import { inferFieldFormatter, isNumericValue } from '@quent/utils';
import type { StatValue, ContinuousPaletteName } from '@quent/utils';
import { continuousColor } from '@quent/utils';
+
+// Re-exported for consumers that still import it from here; defined in `@quent/utils`
+export { isNumericValue };
+
import type {
StatGroupExpandedRow,
GroupKeyEntry,
@@ -23,7 +27,7 @@ export interface GroupIndexDef {
getLabel: (row: StatGroupExpandedRow) => string;
}
-export function formatNumericStat(n: number | null, statName: string): string {
+export function formatNumericStat(n: number | bigint | null, statName: string): string {
if (n === null) return '-';
return inferFieldFormatter(statName)(n);
}
@@ -43,16 +47,12 @@ export function itemHasId(items: Iterable, target: ReadonlySet):
export function formatStatValue(value: StatValue, statName: string): string {
if (value === null || value === undefined) return '-';
- if (typeof value === 'number') return formatNumericStat(value, statName);
+ if (isNumericValue(value)) return formatNumericStat(value, statName);
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (Array.isArray(value)) return value.join(', ');
return String(value);
}
-export function isNumericValue(v: StatValue): v is number {
- return typeof v === 'number';
-}
-
// --- color gradient ---
export function gradientBg(
@@ -173,7 +173,7 @@ export function getSortValue(
if (!isAgg) {
const v = row.values.get(stat);
if (v === undefined) return null;
- return isNumericValue(v) ? v : null;
+ return isNumericValue(v) ? Number(v) : null;
}
const agg = row.aggs.get(stat);
if (!agg || !agg.isNumeric) return null;
@@ -197,7 +197,7 @@ type Accumulator = {
keys: GroupKeyEntry[];
rowKey: string;
values: Map;
- aggBuckets: Map;
+ aggBuckets: Map;
itemIds: Set;
itemScopeIds: Map;
itemType: string;
@@ -251,11 +251,13 @@ export function buildPivotedRows(
} else {
let bucket = group.aggBuckets.get(row.statisticName);
if (!bucket) {
- bucket = { nums: [], count: 0 };
+ bucket = { nums: [], bigints: [], count: 0 };
group.aggBuckets.set(row.statisticName, bucket);
}
bucket.count++;
- if (isNumericValue(row.value)) {
+ if (typeof row.value === 'bigint') {
+ bucket.bigints.push(row.value);
+ } else if (typeof row.value === 'number') {
bucket.nums.push(row.value);
}
}
@@ -266,17 +268,37 @@ export function buildPivotedRows(
const aggs = new Map();
if (isAggregating) {
for (const [stat, bucket] of group.aggBuckets) {
- const hasNum = bucket.nums.length > 0;
- const sum = hasNum ? bucket.nums.reduce((a, b) => a + b, 0) : null;
- const mean = hasNum ? sum! / bucket.nums.length : null;
- const min = hasNum ? Math.min(...bucket.nums) : null;
- const max = hasNum ? Math.max(...bucket.nums) : null;
+ const onlyBigints = bucket.bigints.length > 0 && bucket.nums.length === 0;
+ const allNums = onlyBigints
+ ? bucket.bigints.map(Number)
+ : [...bucket.nums, ...bucket.bigints.map(Number)];
+ const hasNum = allNums.length > 0;
+
+ let sum: number | null = null;
+ let min: number | null = null;
+ let max: number | null = null;
+ let mean: number | null = null;
let stdev: number | null = null;
- if (mean !== null && bucket.nums.length > 1) {
- const variance =
- bucket.nums.reduce((acc, v) => acc + (v - mean) ** 2, 0) / (bucket.nums.length - 1);
- stdev = Math.sqrt(variance);
+
+ if (hasNum) {
+ if (onlyBigints) {
+ // Use bigint arithmetic for sum/min/max to avoid precision loss
+ sum = Number(bucket.bigints.reduce((a, b) => a + b, 0n));
+ min = Number(bucket.bigints.reduce((a, b) => (a < b ? a : b)));
+ max = Number(bucket.bigints.reduce((a, b) => (a > b ? a : b)));
+ } else {
+ sum = allNums.reduce((a, b) => a + b, 0);
+ min = Math.min(...allNums);
+ max = Math.max(...allNums);
+ }
+ mean = sum / allNums.length;
+ if (allNums.length > 1) {
+ const variance =
+ allNums.reduce((acc, v) => acc + (v - mean!) ** 2, 0) / (allNums.length - 1);
+ stdev = Math.sqrt(variance);
+ }
}
+
aggs.set(stat, { sum, mean, min, max, stdev, count: bucket.count, isNumeric: hasNum });
}
}
diff --git a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx
index 4eb5330ae..07cf49b16 100644
--- a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx
+++ b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx
@@ -26,7 +26,7 @@ import {
useSetHighlightedNodeIds,
} from '@quent/hooks';
import { parseCustomStatistics } from '../lib/queryBundle.utils';
-import { inferFieldFormatter } from '@quent/utils';
+import { inferFieldFormatter, isNumericValue } from '@quent/utils';
import { DataText } from '../ui/data-text';
import { NodeFlowBar } from './NodeFlowBar';
@@ -124,7 +124,7 @@ export const QueryPlanNode = memo(({ data }: { data: QueryPlanNodeData }) => {
const formattedColorFieldValue =
colorFieldValue === null
? null
- : typeof colorFieldValue === 'number'
+ : isNumericValue(colorFieldValue)
? inferFieldFormatter(colorField!)(colorFieldValue)
: String(colorFieldValue);
diff --git a/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts b/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts
index 16d4b1125..356892e9a 100644
--- a/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts
+++ b/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.test.ts
@@ -156,6 +156,22 @@ describe('computeEdgeColoring', () => {
expect(result.values.get('e2')).toBe(40);
});
+ it('returns continuous coloring for bigint values (coerced to number)', () => {
+ // Bigint stats stay continuous (coerced to number), not categorical.
+ const edges = [
+ makeEdge('e1', [{ key: 'rows', value: 10n }]),
+ makeEdge('e2', [{ key: 'rows', value: 40n }]),
+ ];
+ const result = computeEdgeColoring(edges, 'rows', 'light');
+ expect(result?.type).toBe('continuous');
+ if (result?.type !== 'continuous') return;
+ expect(result.min).toBe(10);
+ expect(result.max).toBe(40);
+ expect(result.values.get('e1')).toBe(10);
+ expect(result.values.get('e2')).toBe(40);
+ expect(typeof result.values.get('e1')).toBe('number');
+ });
+
it('returns categorical coloring for string values', () => {
const edges = [
makeEdge('e1', [{ key: 'type', value: 'hash' }]),
@@ -231,6 +247,19 @@ describe('computeEdgeWidthConfig', () => {
expect(result!.values.get('e3')).toBe(12);
});
+ it('coerces bigint values (large U64/I64) into the number width scale', () => {
+ const edges = [
+ makeEdge('e1', [{ key: 'rows', value: 5n }]),
+ makeEdge('e2', [{ key: 'rows', value: 20n }]),
+ ];
+ const result = computeEdgeWidthConfig(edges, 'rows');
+ expect(result).not.toBeNull();
+ expect(result!.min).toBe(5);
+ expect(result!.max).toBe(20);
+ expect(result!.values.get('e1')).toBe(5);
+ expect(typeof result!.values.get('e1')).toBe('number');
+ });
+
it('returns equal min/max for a single edge', () => {
const edges = [makeEdge('e1', [{ key: 'rows', value: 7 }])];
const result = computeEdgeWidthConfig(edges, 'rows');
diff --git a/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.ts b/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.ts
index 660a477c7..4dd2202fa 100644
--- a/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.ts
+++ b/ui/packages/@quent/components/src/services/query-plan/dagFieldProcessing.ts
@@ -3,7 +3,7 @@
import type { DAGNode, DAGEdge, NodeColoring, EdgeWidthConfig, EdgeColoring } from './types';
import { parseCustomStatistics } from '../../lib/queryBundle.utils';
-import { getActivePalette, type PaletteTheme } from '@quent/utils';
+import { getActivePalette, isNumericValue, type PaletteTheme } from '@quent/utils';
export function computeNodeColoring(
nodes: DAGNode[],
@@ -19,11 +19,12 @@ export function computeNodeColoring(
});
if (!entries.length) return null;
- if (entries.every(e => typeof e.value === 'number')) {
- const nums = entries.map(e => e.value as number);
+ // handle potential bigints
+ if (entries.every(e => isNumericValue(e.value))) {
+ const nums = entries.map(e => Number(e.value));
return {
type: 'continuous',
- values: new Map(entries.map(e => [e.id, e.value as number])),
+ values: new Map(entries.map(e => [e.id, Number(e.value)])),
min: Math.min(...nums),
max: Math.max(...nums),
};
@@ -53,11 +54,12 @@ export function computeEdgeColoring(
});
if (!entries.length) return null;
- if (entries.every(e => typeof e.value === 'number')) {
- const nums = entries.map(e => e.value as number);
+ // handle potential bigints
+ if (entries.every(e => isNumericValue(e.value))) {
+ const nums = entries.map(e => Number(e.value));
return {
type: 'continuous',
- values: new Map(entries.map(e => [e.id, e.value as number])),
+ values: new Map(entries.map(e => [e.id, Number(e.value)])),
min: Math.min(...nums),
max: Math.max(...nums),
};
@@ -79,8 +81,8 @@ export function computeEdgeWidthConfig(edges: DAGEdge[], field: string | null):
const entries = edges.flatMap(edge => {
const stat = (edge.portStats ?? []).find(s => s.key === field);
- if (typeof stat?.value !== 'number') return [];
- return [{ id: edge.id, value: stat.value }];
+ if (stat?.value == null || !isNumericValue(stat.value)) return [];
+ return [{ id: edge.id, value: Number(stat.value) }];
});
if (!entries.length) return null;
diff --git a/ui/packages/@quent/utils/src/dagTypes.ts b/ui/packages/@quent/utils/src/dagTypes.ts
index f7df56e9f..6e6090d2e 100644
--- a/ui/packages/@quent/utils/src/dagTypes.ts
+++ b/ui/packages/@quent/utils/src/dagTypes.ts
@@ -1,6 +1,8 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
+import type { DynamicValue } from './types/index';
+
// Pure-data types for DAG coloring, width configuration, and node/edge shapes.
// These are kept in @quent/utils to avoid circular dependencies between
// @quent/hooks (which holds DAG atoms) and @quent/components (which holds DAG rendering).
@@ -59,7 +61,7 @@ export const DAG_LAYOUT_DIRECTION = {
export type DagLayoutDirection = (typeof DAG_LAYOUT_DIRECTION)[keyof typeof DAG_LAYOUT_DIRECTION];
-export type StatValue = string | number | boolean | null | string[];
+export type StatValue = DynamicValue | null;
export interface DAGNode {
id: string;
diff --git a/ui/packages/@quent/utils/src/formatters.test.ts b/ui/packages/@quent/utils/src/formatters.test.ts
index eb4ddbe69..d62511dc2 100644
--- a/ui/packages/@quent/utils/src/formatters.test.ts
+++ b/ui/packages/@quent/utils/src/formatters.test.ts
@@ -19,6 +19,7 @@ import {
unwrapTaggedValue,
formatAttributeValue,
isBytesRateStat,
+ isNumericValue,
} from './formatters';
import type { QuantitySpec } from './types/index';
@@ -160,6 +161,10 @@ describe('formatWithPrefix (None)', () => {
it('respects decimals', () => {
expect(formatWithPrefix(3.14159, 'Hz', 'None', 3)).toBe('3.142 Hz');
});
+
+ it('preserves unprefixed bigint precision above Number.MAX_SAFE_INTEGER', () => {
+ expect(formatWithPrefix(9007199254740993n, 'Hz', 'None', 0)).toBe('9007199254740993 Hz');
+ });
});
// ---------------------------------------------------------------------------
@@ -196,6 +201,10 @@ describe('formatWithPrefix (Si, values >= 1)', () => {
it('handles negative values', () => {
expect(formatWithPrefix(-1500, 'Hz', 'Si')).toBe('-1.5 kHz');
});
+
+ it('normalizes a rounded mantissa into the next prefix', () => {
+ expect(formatWithPrefix(999999999999999n, 'Hz', 'Si', 1)).toBe('1.0 PHz');
+ });
});
// ---------------------------------------------------------------------------
@@ -254,6 +263,36 @@ describe('formatWithPrefix (Iec)', () => {
});
});
+// ---------------------------------------------------------------------------
+// formatWithPrefix — bigint values
+// ---------------------------------------------------------------------------
+
+describe('formatWithPrefix (bigint)', () => {
+ it('does not throw on a BigInt (reported repro)', () => {
+ expect(() => formatWithPrefix(1024n, 'B', 'Iec')).not.toThrow();
+ });
+
+ it('formats zero bigint', () => {
+ expect(formatWithPrefix(0n, 'B', 'Iec')).toBe('0 B');
+ expect(formatWithPrefix(0n, '', 'None')).toBe('0');
+ });
+
+ it('scales positive bigints like numbers', () => {
+ expect(formatWithPrefix(1500n, 'Hz', 'Si')).toBe('1.5 kHz');
+ expect(formatWithPrefix(1024n, 'B', 'Iec')).toBe('1.0 KiB');
+ expect(formatWithPrefix(1073741824n, 'B', 'Iec')).toBe('1.0 GiB');
+ });
+
+ it('handles negative bigints', () => {
+ expect(formatWithPrefix(-1500n, 'Hz', 'Si')).toBe('-1.5 kHz');
+ expect(formatWithPrefix(-1024n, 'B', 'Iec')).toBe('-1.0 KiB');
+ });
+
+ it('formats bigints with the None prefix system', () => {
+ expect(formatWithPrefix(42n, 'Hz', 'None')).toBe('42.0 Hz');
+ });
+});
+
// ---------------------------------------------------------------------------
// formatBytes
// ---------------------------------------------------------------------------
@@ -289,6 +328,11 @@ describe('formatNumber', () => {
expect(formatNumber(0.001234)).toBe('0.00123');
expect(formatNumber(12345.6)).toBe('12,300');
});
+
+ it('formats bigints losslessly above Number.MAX_SAFE_INTEGER', () => {
+ // 9007199254740993 is not representable as a JS number (rounds to ...992).
+ expect(formatNumber(9007199254740993n)).toBe('9,007,199,254,740,993');
+ });
});
// ---------------------------------------------------------------------------
@@ -423,6 +467,12 @@ describe('inferFieldFormatter', () => {
expect(inferFieldFormatter('custom_stat')(42)).toBe('42');
expect(inferFieldFormatter('custom_stat')(3.14159)).toBe('3.1416');
});
+
+ it('accepts bigint values (large U64/I64 stats)', () => {
+ expect(inferFieldFormatter('spill_bytes')(1073741824n)).toBe('1.00 GiB');
+ expect(inferFieldFormatter('output_rows')(1500n)).toBe('1.50 k');
+ expect(inferFieldFormatter('custom_stat')(42n)).toBe('42');
+ });
});
// ---------------------------------------------------------------------------
@@ -551,6 +601,21 @@ describe('unwrapTaggedValue', () => {
});
});
+describe('isNumericValue', () => {
+ it('accepts numbers and bigints', () => {
+ expect(isNumericValue(42)).toBe(true);
+ expect(isNumericValue(0)).toBe(true);
+ expect(isNumericValue(42n)).toBe(true);
+ });
+
+ it('rejects non-numeric StatValue members', () => {
+ expect(isNumericValue('42')).toBe(false);
+ expect(isNumericValue(true)).toBe(false);
+ expect(isNumericValue(null)).toBe(false);
+ expect(isNumericValue(['1', '2'])).toBe(false);
+ });
+});
+
describe('formatAttributeValue', () => {
it('byte-formats bytes-like keys', () => {
expect(formatAttributeValue('input_bytes', { U64: 1073741824 })).toBe('1.00 GiB');
@@ -565,6 +630,11 @@ describe('formatAttributeValue', () => {
it('renders missing values as a dash', () => {
expect(formatAttributeValue('anything', null)).toBe('—');
});
+
+ it('handles bigint entity-attribute values (large U64/I64)', () => {
+ expect(formatAttributeValue('input_bytes', 1073741824n)).toBe('1.00 GiB');
+ expect(formatAttributeValue('current_operator_id', { U64: 42n })).toBe('42');
+ });
});
describe('bytes-rate attribute keys', () => {
diff --git a/ui/packages/@quent/utils/src/formatters.ts b/ui/packages/@quent/utils/src/formatters.ts
index fbfc04319..358b8ed69 100644
--- a/ui/packages/@quent/utils/src/formatters.ts
+++ b/ui/packages/@quent/utils/src/formatters.ts
@@ -113,15 +113,26 @@ const IEC: readonly [number, string][] = [
];
export function formatWithPrefix(
- value: number,
+ value: number | bigint,
symbol: string,
prefixSystem: PrefixSystem,
decimals: number = 1
): string {
- if (value === 0) return symbol ? `0 ${symbol}` : '0';
+ // For the unprefixed case with a bigint, avoid Number() coercion to preserve full precision
+ if (prefixSystem === 'None' && typeof value === 'bigint') {
+ if (value === 0n) return symbol ? `0 ${symbol}` : '0';
+ const absB = value < 0n ? -value : value;
+ const signB = value < 0n ? '-' : '';
+ const str = decimals === 0 ? absB.toString() : `${absB}.${'0'.repeat(decimals)}`;
+ return symbol ? `${signB}${str} ${symbol}` : `${signB}${str}`;
+ }
- const abs = value < 0 ? -value : value;
- const sign = value < 0 ? '-' : '';
+ const num = typeof value === 'bigint' ? Number(value) : value;
+
+ if (num === 0) return symbol ? `0 ${symbol}` : '0';
+
+ const abs = num < 0 ? -num : num;
+ const sign = num < 0 ? '-' : '';
if (prefixSystem === 'None') {
return symbol ? `${sign}${abs.toFixed(decimals)} ${symbol}` : `${sign}${abs.toFixed(decimals)}`;
@@ -139,11 +150,18 @@ export function formatWithPrefix(
}
const table = prefixSystem === 'Iec' ? IEC : SI_UP;
+ const roundingFactor = 10 ** decimals;
for (let i = 0; i < table.length; i++) {
if (abs >= table[i][0]) {
const scaled = abs / table[i][0];
- const prefix = table[i][1];
- return `${sign}${scaled.toFixed(decimals)} ${prefix}${symbol}`;
+ // If floating-point rounding bumps the mantissa into the next prefix, step up
+ if (
+ i > 0 &&
+ Math.round(scaled * roundingFactor) >= (table[i - 1][0] / table[i][0]) * roundingFactor
+ ) {
+ return `${sign}${(abs / table[i - 1][0]).toFixed(decimals)} ${table[i - 1][1]}${symbol}`;
+ }
+ return `${sign}${scaled.toFixed(decimals)} ${table[i][1]}${symbol}`;
}
}
const last = table[table.length - 1];
@@ -214,8 +232,9 @@ export function formatQuantityCompact(
* Integers are formatted with commas (e.g. 1,234,567).
* Floats are rounded to 3 significant figures (e.g. 0.00123, 1.23, 12,300).
*/
-export function formatNumber(value: number): string {
- if (Number.isInteger(value)) {
+export function formatNumber(value: number | bigint): string {
+ // bigint formats losslessly via Intl and is always an integer.
+ if (typeof value === 'bigint' || Number.isInteger(value)) {
return new Intl.NumberFormat().format(value);
}
return new Intl.NumberFormat(undefined, { maximumSignificantDigits: 3 }).format(value);
@@ -236,7 +255,7 @@ export function formatNumberWithMaxFractionDigits(
return new Intl.NumberFormat(undefined, { maximumFractionDigits }).format(value);
}
-export function formatBytes(value: number, decimals = 1): string {
+export function formatBytes(value: number | bigint, decimals = 1): string {
return formatWithPrefix(value, 'B', 'Iec', decimals);
}
@@ -250,6 +269,10 @@ export function isBytesStat(name: string): boolean {
);
}
+export function isNumericValue(v: StatValue): v is number | bigint {
+ return typeof v === 'number' || typeof v === 'bigint';
+}
+
function unwrapToString(val: unknown): string {
const result = unwrapTaggedValue(val);
return Array.isArray(result) ? result.join('\n') : String(result ?? '');
@@ -264,10 +287,10 @@ export function unwrapTaggedValue(val: unknown): StatValue {
switch (true) {
case val === null || val === undefined:
return null;
- case typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean':
- return val as StatValue;
+ case typeof val === 'string' || typeof val === 'number':
+ return val;
case typeof val === 'bigint':
- return Number(val);
+ return val;
case Array.isArray(val):
return (val as unknown[]).map(unwrapToString);
case typeof val === 'object': {
@@ -300,7 +323,7 @@ export function isBytesRateStat(name: string): boolean {
export function formatAttributeValue(key: string, value: unknown): string {
const v = unwrapTaggedValue(value);
if (v == null) return '—';
- if (typeof v === 'number') {
+ if (isNumericValue(v)) {
if (isBytesRateStat(key)) return formatWithPrefix(v, 'B/s', 'Si', 2);
if (isBytesStat(key)) return formatBytes(v, 2);
return formatNumber(v);
@@ -329,20 +352,25 @@ function formatSiCount(value: number, decimals = 2): string {
* Infer a numeric display formatter from a statistic/field name (DAG labels, pivot cells, legends).
* Order: duration (ns) → bytes → row/batch counts → throughput → ratios → default table number.
*/
-export function inferFieldFormatter(fieldName: string): (value: number) => string {
- if (fieldName.endsWith('_ns')) return v => formatDuration(v / 1e6);
- if (isBytesStat(fieldName)) return v => formatBytes(v, 2);
- if (isCountStat(fieldName)) return v => formatSiCount(v, 2);
- if (fieldName.endsWith('_mbs')) return v => `${v.toFixed(1)} MB/s`;
- if (
- fieldName.endsWith('_ratio') ||
- fieldName.endsWith('_fraction') ||
- fieldName.endsWith('_fpr') ||
- fieldName.endsWith('_selectivity') ||
- fieldName.endsWith('_rate')
- )
- return v => `${(v * 100).toFixed(1)}%`;
- return v => formatNumberWithMaxFractionDigits(v, 4);
+export function inferFieldFormatter(fieldName: string): (value: number | bigint) => string {
+ return (value: number | bigint): string => {
+ const num = typeof value === 'bigint' ? Number(value) : value;
+ if (fieldName.endsWith('_ns')) return formatDuration(num / 1e6);
+ if (isBytesStat(fieldName)) return formatBytes(value, 2);
+ if (isCountStat(fieldName)) return formatSiCount(num, 2);
+ if (fieldName.endsWith('_mbs')) return `${num.toFixed(1)} MB/s`;
+ if (
+ fieldName.endsWith('_ratio') ||
+ fieldName.endsWith('_fraction') ||
+ fieldName.endsWith('_fpr') ||
+ fieldName.endsWith('_selectivity') ||
+ fieldName.endsWith('_rate')
+ )
+ return `${(num * 100).toFixed(1)}%`;
+ return typeof value === 'bigint'
+ ? formatNumber(value)
+ : formatNumberWithMaxFractionDigits(num, 4);
+ };
}
/**
diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts
index 32a2c0f06..406aeac98 100644
--- a/ui/packages/@quent/utils/src/index.ts
+++ b/ui/packages/@quent/utils/src/index.ts
@@ -44,6 +44,7 @@ export {
formatAttributeValue,
unwrapTaggedValue,
inferFieldFormatter,
+ isNumericValue,
} from './formatters';
// Rust-generated TypeScript types