From b7c38fb228fb1125506a34ceed3e53ee85c1e7e8 Mon Sep 17 00:00:00 2001 From: digua Date: Fri, 28 Aug 2026 10:02:10 +0800 Subject: [PATCH] fix(insight): bound graph look-ahead allocation Clamp cluster look-ahead values at the shared core boundary so invalid inputs cannot create oversized candidate matrices. Add a regression test that keeps oversized values equivalent to the supported maximum. --- .../query/advanced/__tests__/social.test.ts | 9 +++++++ packages/core/src/query/advanced/social.ts | 25 ++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/core/src/query/advanced/__tests__/social.test.ts b/packages/core/src/query/advanced/__tests__/social.test.ts index bb9589e7..09786a45 100644 --- a/packages/core/src/query/advanced/__tests__/social.test.ts +++ b/packages/core/src/query/advanced/__tests__/social.test.ts @@ -213,6 +213,15 @@ describe('social analysis', () => { ) }) + it('caps oversized look-ahead values before allocating candidate indexes', () => { + const messages = Array.from({ length: 12 }, (_, index) => ({ senderId: index + 1, ts: 100 + index })) + + assert.deepEqual( + accumulateCoOccurrencePairBatches([messages], { lookAhead: 100_000 }), + accumulateCoOccurrencePairBatches([messages], { lookAhead: 10 }) + ) + }) + it('keeps large streamed aggregation identical to the in-memory algorithm', () => { const senderPattern = [1, 1, 2, 3, 2, 4, 5] const messages = Array.from({ length: 100_005 }, (_, index) => ({ diff --git a/packages/core/src/query/advanced/social.ts b/packages/core/src/query/advanced/social.ts index 67fea1dc..884f1d40 100644 --- a/packages/core/src/query/advanced/social.ts +++ b/packages/core/src/query/advanced/social.ts @@ -309,9 +309,22 @@ export interface SelectedCoOccurrencePairStats extends CoOccurrencePairStats { } const DEFAULT_CLUSTER_OPTIONS = { lookAhead: 3, decaySeconds: 120, topEdges: 100 } +const MAX_CLUSTER_LOOK_AHEAD = 10 const DEFAULT_SELECTED_PROXIMITY_MAX_GAP_SECONDS = 1800 const CO_OCCURRENCE_MESSAGE_BATCH_SIZE = 20_000 +function resolveClusterOptions(options?: ClusterGraphOptions): Required { + const requestedLookAhead = options?.lookAhead ?? DEFAULT_CLUSTER_OPTIONS.lookAhead + const lookAhead = Number.isFinite(requestedLookAhead) + ? Math.min(MAX_CLUSTER_LOOK_AHEAD, Math.max(0, Math.ceil(requestedLookAhead))) + : DEFAULT_CLUSTER_OPTIONS.lookAhead + return { + lookAhead, + decaySeconds: options?.decaySeconds ?? DEFAULT_CLUSTER_OPTIONS.decaySeconds, + topEdges: options?.topEdges ?? DEFAULT_CLUSTER_OPTIONS.topEdges, + } +} + function roundNum(value: number, digits = 4): number { const factor = 10 ** digits return Math.round(value * factor) / factor @@ -325,7 +338,7 @@ export function accumulateCoOccurrencePairs( messages: CoOccurrenceMessage[], options?: ClusterGraphOptions ): CoOccurrencePairStats[] { - const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } + const opts = resolveClusterOptions(options) const pairStatsBySource = new Map>() const pairs: CoOccurrencePairStats[] = [] @@ -398,8 +411,8 @@ function accumulateCoOccurrenceColumns( timestamps: Float64Array, options?: ClusterGraphOptions ): CoOccurrencePairStats[] { - const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } - const lookAhead = Math.max(0, Math.ceil(opts.lookAhead)) + const opts = resolveClusterOptions(options) + const lookAhead = opts.lookAhead const candidateIndexes = buildDistinctSpeakerLookAhead(senderIds.length, (index) => senderIds[index], lookAhead) const pairStatsBySource = new Map>() const pairs: CoOccurrencePairStats[] = [] @@ -459,8 +472,8 @@ export function accumulateSelectedCoOccurrencePairs( selectedPairs: Array, options?: ClusterGraphOptions & { maxAnchorsPerPair?: number; maxGapSeconds?: number } ): SelectedCoOccurrencePairStats[] { - const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } - const lookAhead = Math.max(0, Math.ceil(opts.lookAhead)) + const opts = resolveClusterOptions(options) + const lookAhead = opts.lookAhead const maxAnchorsPerPair = Math.max(0, Math.floor(options?.maxAnchorsPerPair ?? 2)) const maxGapSeconds = Math.max(0, options?.maxGapSeconds ?? DEFAULT_SELECTED_PROXIMITY_MAX_GAP_SECONDS) const selectedKeys = new Set(selectedPairs.map(([left, right]) => clusterPairKey(left, right))) @@ -615,7 +628,7 @@ export function getClusterGraph( filter?: TimeFilter, options?: ClusterGraphOptions ): ClusterGraphData { - const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } + const opts = resolveClusterOptions(options) const emptyResult: ClusterGraphData = { nodes: [], links: [],