From f4ed5d31009d061811e09cae17989f54a0c4868e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Thu, 5 Mar 2026 10:02:24 -0800 Subject: [PATCH 1/5] feat: improved attribute sorting with entropy scoring and proportional comparison Replace the basic max-delta sorting with proportional normalization (computeComparisonScore) that normalizes group sizes before computing deltas. Add Shannon entropy scoring (computeEntropyScore) for future distribution mode, and semantic boost for well-known OTel attributes. Closes #1826 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/attribute-sorting.md | 5 + packages/app/src/components/DBDeltaChart.tsx | 19 ++-- .../__tests__/deltaChartScoring.test.ts | 92 +++++++++++++++ .../app/src/components/deltaChartUtils.ts | 105 ++++++++++++++++++ 4 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 .changeset/attribute-sorting.md create mode 100644 packages/app/src/components/__tests__/deltaChartScoring.test.ts diff --git a/.changeset/attribute-sorting.md b/.changeset/attribute-sorting.md new file mode 100644 index 0000000000..11e0b0aede --- /dev/null +++ b/.changeset/attribute-sorting.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: improved attribute sorting with entropy scoring and proportional comparison diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index d38d44ff98..62f1711b59 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -22,11 +22,11 @@ import { getFirstTimestampValueExpression } from '@/source'; import { SQLPreview } from './ChartSQLPreview'; import { + computeComparisonScore, getPropertyStatistics, getStableSampleExpression, isDenylisted, isHighCardinality, - mergeValueStatisticsMaps, SAMPLE_SIZE, } from './deltaChartUtils'; import { @@ -242,7 +242,10 @@ export default function DBDeltaChart({ if (uniqueKeys.size === 0) { uniqueKeys = new Set([...inlierValueOccurences.keys()]); } - // Now process the keys to find the ones with the highest delta between outlier and inlier percentages + // Sort by proportional comparison score (normalizes group sizes). + // TODO: When #1824 (always-on distribution) merges, use computeEntropyScore + // for distribution mode (no selection) and computeComparisonScore only when + // a selection is active (hasSelection flag from #1824). const sortedProperties = Array.from(uniqueKeys) .map(key => { const inlierCount = @@ -250,16 +253,10 @@ export default function DBDeltaChart({ const outlierCount = outlierValueOccurences.get(key) ?? new Map(); - const mergedArray = mergeValueStatisticsMaps(outlierCount, inlierCount); - let maxValueDelta = 0; - mergedArray.forEach(item => { - const delta = Math.abs(item.outlierCount - item.inlierCount); - if (delta > maxValueDelta) { - maxValueDelta = delta; - } - }); + // Use proportional comparison scoring which normalizes group sizes + const sortScore = computeComparisonScore(outlierCount, inlierCount); - return [key, maxValueDelta] as const; + return [key, sortScore] as const; }) .sort((a, b) => b[1] - a[1]) .map(a => a[0]); diff --git a/packages/app/src/components/__tests__/deltaChartScoring.test.ts b/packages/app/src/components/__tests__/deltaChartScoring.test.ts new file mode 100644 index 0000000000..5306d24758 --- /dev/null +++ b/packages/app/src/components/__tests__/deltaChartScoring.test.ts @@ -0,0 +1,92 @@ +import { + computeComparisonScore, + computeEntropyScore, + semanticBoost, +} from '../deltaChartUtils'; + +describe('computeComparisonScore', () => { + it('returns 0 for empty maps', () => { + expect(computeComparisonScore(new Map(), new Map())).toBe(0); + }); + + it('returns 0 when both groups have identical proportions', () => { + const outlier = new Map([['GET', 80], ['POST', 20]]); + const inlier = new Map([['GET', 40], ['POST', 10]]); + expect(computeComparisonScore(outlier, inlier)).toBeCloseTo(0); + }); + + it('returns high score for different proportions', () => { + const outlier = new Map([['error', 90], ['ok', 10]]); + const inlier = new Map([['error', 10], ['ok', 90]]); + expect(computeComparisonScore(outlier, inlier)).toBeGreaterThan(70); + }); + + it('uses raw delta when one group has no data', () => { + const outlier = new Map([['error', 50]]); + expect(computeComparisonScore(outlier, new Map())).toBe(50); + }); + + it('normalizes by group sum so different sample sizes produce same score', () => { + const outlierSmall = new Map([['GET', 8], ['POST', 2]]); + const outlierLarge = new Map([['GET', 800], ['POST', 200]]); + const inlier = new Map([['GET', 50], ['POST', 50]]); + const scoreSmall = computeComparisonScore(outlierSmall, inlier); + const scoreLarge = computeComparisonScore(outlierLarge, inlier); + expect(scoreSmall).toBeCloseTo(scoreLarge, 1); + }); +}); + +describe('computeEntropyScore', () => { + it('returns 0 for single-value fields', () => { + expect(computeEntropyScore(new Map([['only', 100]]))).toBe(0); + }); + + it('returns 0 for empty map', () => { + expect(computeEntropyScore(new Map())).toBe(0); + }); + + it('returns ~0 for perfectly uniform distribution', () => { + expect(computeEntropyScore(new Map([['a', 50], ['b', 50]]))).toBeCloseTo(0); + }); + + it('returns high score for skewed distribution', () => { + const score = computeEntropyScore(new Map([['ok', 99], ['error', 1]])); + expect(score).toBeGreaterThan(0.5); + }); + + it('ranks more-skewed fields higher', () => { + const scoreA = computeEntropyScore(new Map([['a', 95], ['b', 5]])); + const scoreB = computeEntropyScore(new Map([['a', 60], ['b', 40]])); + expect(scoreA).toBeGreaterThan(scoreB); + }); + + it('returns ~0 for uniform 3-value field', () => { + expect(computeEntropyScore(new Map([['a', 33.33], ['b', 33.33], ['c', 33.34]]))).toBeCloseTo(0, 2); + }); + + it('handles power-law distributions', () => { + const powerLaw = new Map([['v1', 50], ['v2', 25], ['v3', 12], ['v4', 6], ['v5', 4], ['v6', 2], ['v7', 1]]); + const score = computeEntropyScore(powerLaw); + expect(score).toBeGreaterThan(0.1); + expect(score).toBeLessThan(1); + }); +}); + +describe('semanticBoost', () => { + it('boosts well-known OTel attributes', () => { + expect(semanticBoost('ResourceAttributes.service.name')).toBe(1); + expect(semanticBoost('SpanAttributes.http.method')).toBe(1); + expect(semanticBoost('SpanAttributes.http.status_code')).toBe(1); + expect(semanticBoost('SpanAttributes.error')).toBe(1); + }); + + it('returns 0 for non-boosted attributes', () => { + expect(semanticBoost('SpanAttributes.custom.field')).toBe(0); + expect(semanticBoost('TraceId')).toBe(0); + }); + + it('is case-insensitive', () => { + expect(semanticBoost('ResourceAttributes.Service.Name')).toBe(1); + expect(semanticBoost('SpanAttributes.HTTP.METHOD')).toBe(1); + }); +}); diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index 1fe08c793b..83585a8c0d 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -323,3 +323,108 @@ export function computeEffectiveSampleSize(totalCount: number): number { Math.max(MIN_SAMPLE_SIZE, Math.ceil(totalCount * SAMPLE_RATIO)), ); } + +// --------------------------------------------------------------------------- +// Attribute sorting and scoring +// --------------------------------------------------------------------------- + +/** + * Comparison mode scoring: normalizes each group's percentages to sum to 100% + * before computing max delta. Fields with identical proportional distributions + * score 0 regardless of coverage rate differences. + */ +export function computeComparisonScore( + outlierValues: Map, + inlierValues: Map, +): number { + const allValues = new Set([...outlierValues.keys(), ...inlierValues.keys()]); + if (allValues.size === 0) return 0; + + let outlierSum = 0; + let inlierSum = 0; + outlierValues.forEach(v => (outlierSum += v)); + inlierValues.forEach(v => (inlierSum += v)); + + if (outlierSum === 0 && inlierSum === 0) return 0; + if (outlierSum === 0 || inlierSum === 0) { + let maxDelta = 0; + allValues.forEach(value => { + const delta = Math.abs( + (outlierValues.get(value) ?? 0) - (inlierValues.get(value) ?? 0), + ); + if (delta > maxDelta) maxDelta = delta; + }); + return maxDelta; + } + + let maxDelta = 0; + allValues.forEach(value => { + const outlierNorm = ((outlierValues.get(value) ?? 0) / outlierSum) * 100; + const inlierNorm = ((inlierValues.get(value) ?? 0) / inlierSum) * 100; + const delta = Math.abs(outlierNorm - inlierNorm); + if (delta > maxDelta) maxDelta = delta; + }); + return maxDelta; +} + +/** + * Shannon entropy-based distribution score for sorting properties. + * Returns [0, 1]: 1 = maximally useful (low entropy, dominant value among several), + * 0 = not useful (single value, empty, or perfectly uniform). + */ +export function computeEntropyScore( + valuePercentages: Map, +): number { + const nValues = valuePercentages.size; + if (nValues <= 1) return 0; + + let totalPct = 0; + valuePercentages.forEach(pct => { + totalPct += pct; + }); + if (totalPct === 0) return 0; + + let entropy = 0; + valuePercentages.forEach(pct => { + const p = pct / totalPct; + if (p > 0) { + entropy -= p * Math.log2(p); + } + }); + + const maxEntropy = Math.log2(nValues); + if (maxEntropy === 0) return 0; + + return 1 - entropy / maxEntropy; +} + +/** Well-known OTel attribute suffixes that get a score boost */ +const BOOSTED_ATTRIBUTE_SUFFIXES = [ + 'service.name', + 'http.method', + 'http.request.method', + 'http.status_code', + 'http.response.status_code', + 'error', + 'error.type', + 'deployment.environment', + 'deployment.environment.name', + 'rpc.method', + 'rpc.service', + 'db.system', + 'db.operation', + 'messaging.system', + 'messaging.operation', +]; + +/** + * Returns 1 for well-known OTel attributes, 0 otherwise. + * Callers scale this as a tiebreaker (e.g., * 0.1) and only apply when baseScore > 0. + */ +export function semanticBoost(key: string): number { + const lowerKey = key.toLowerCase(); + for (const suffix of BOOSTED_ATTRIBUTE_SUFFIXES) { + if (lowerKey.endsWith(suffix)) return 1; + } + return 0; +} From 95e76bc3ba75cc7b2048ab09e32022b63a103c3e Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Thu, 5 Mar 2026 10:34:20 -0800 Subject: [PATCH 2/5] fix: prettier formatting in scoring tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/deltaChartScoring.test.ts | 85 ++++++++++++++++--- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/__tests__/deltaChartScoring.test.ts b/packages/app/src/components/__tests__/deltaChartScoring.test.ts index 5306d24758..a311e8baf7 100644 --- a/packages/app/src/components/__tests__/deltaChartScoring.test.ts +++ b/packages/app/src/components/__tests__/deltaChartScoring.test.ts @@ -10,14 +10,26 @@ describe('computeComparisonScore', () => { }); it('returns 0 when both groups have identical proportions', () => { - const outlier = new Map([['GET', 80], ['POST', 20]]); - const inlier = new Map([['GET', 40], ['POST', 10]]); + const outlier = new Map([ + ['GET', 80], + ['POST', 20], + ]); + const inlier = new Map([ + ['GET', 40], + ['POST', 10], + ]); expect(computeComparisonScore(outlier, inlier)).toBeCloseTo(0); }); it('returns high score for different proportions', () => { - const outlier = new Map([['error', 90], ['ok', 10]]); - const inlier = new Map([['error', 10], ['ok', 90]]); + const outlier = new Map([ + ['error', 90], + ['ok', 10], + ]); + const inlier = new Map([ + ['error', 10], + ['ok', 90], + ]); expect(computeComparisonScore(outlier, inlier)).toBeGreaterThan(70); }); @@ -27,9 +39,18 @@ describe('computeComparisonScore', () => { }); it('normalizes by group sum so different sample sizes produce same score', () => { - const outlierSmall = new Map([['GET', 8], ['POST', 2]]); - const outlierLarge = new Map([['GET', 800], ['POST', 200]]); - const inlier = new Map([['GET', 50], ['POST', 50]]); + const outlierSmall = new Map([ + ['GET', 8], + ['POST', 2], + ]); + const outlierLarge = new Map([ + ['GET', 800], + ['POST', 200], + ]); + const inlier = new Map([ + ['GET', 50], + ['POST', 50], + ]); const scoreSmall = computeComparisonScore(outlierSmall, inlier); const scoreLarge = computeComparisonScore(outlierLarge, inlier); expect(scoreSmall).toBeCloseTo(scoreLarge, 1); @@ -46,26 +67,64 @@ describe('computeEntropyScore', () => { }); it('returns ~0 for perfectly uniform distribution', () => { - expect(computeEntropyScore(new Map([['a', 50], ['b', 50]]))).toBeCloseTo(0); + expect( + computeEntropyScore( + new Map([ + ['a', 50], + ['b', 50], + ]), + ), + ).toBeCloseTo(0); }); it('returns high score for skewed distribution', () => { - const score = computeEntropyScore(new Map([['ok', 99], ['error', 1]])); + const score = computeEntropyScore( + new Map([ + ['ok', 99], + ['error', 1], + ]), + ); expect(score).toBeGreaterThan(0.5); }); it('ranks more-skewed fields higher', () => { - const scoreA = computeEntropyScore(new Map([['a', 95], ['b', 5]])); - const scoreB = computeEntropyScore(new Map([['a', 60], ['b', 40]])); + const scoreA = computeEntropyScore( + new Map([ + ['a', 95], + ['b', 5], + ]), + ); + const scoreB = computeEntropyScore( + new Map([ + ['a', 60], + ['b', 40], + ]), + ); expect(scoreA).toBeGreaterThan(scoreB); }); it('returns ~0 for uniform 3-value field', () => { - expect(computeEntropyScore(new Map([['a', 33.33], ['b', 33.33], ['c', 33.34]]))).toBeCloseTo(0, 2); + expect( + computeEntropyScore( + new Map([ + ['a', 33.33], + ['b', 33.33], + ['c', 33.34], + ]), + ), + ).toBeCloseTo(0, 2); }); it('handles power-law distributions', () => { - const powerLaw = new Map([['v1', 50], ['v2', 25], ['v3', 12], ['v4', 6], ['v5', 4], ['v6', 2], ['v7', 1]]); + const powerLaw = new Map([ + ['v1', 50], + ['v2', 25], + ['v3', 12], + ['v4', 6], + ['v5', 4], + ['v6', 2], + ['v7', 1], + ]); const score = computeEntropyScore(powerLaw); expect(score).toBeGreaterThan(0.1); expect(score).toBeLessThan(1); From 0aadc284dd4c50b99b54b5dbd72aafefa77b8700 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Thu, 5 Mar 2026 14:06:13 -0800 Subject: [PATCH 3/5] fix: normalize empty-group fallback to [0,100] and wire semanticBoost Two fixes from code review: 1. computeComparisonScore empty-group fallback: when one group has no data, normalize the present group's values to percentages [0,100] instead of using raw counts. This prevents single-value fields with high raw counts from inflating their score relative to multi-value fields. Fixes scale inconsistency with distribution mode in #1855. 2. Wire semanticBoost into DBDeltaChart sort as a tiebreaker (0.1 weight, only when baseScore > 0). Previously exported and tested but never called. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/components/DBDeltaChart.tsx | 9 +++++++-- .../__tests__/deltaChartScoring.test.ts | 12 ++++++++++-- packages/app/src/components/deltaChartUtils.ts | 16 +++++++++------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index 0f1ece00f2..e0b0068816 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -27,6 +27,7 @@ import { isDenylisted, isHighCardinality, SAMPLE_SIZE, + semanticBoost, } from './deltaChartUtils'; import { CHART_GAP, @@ -252,8 +253,12 @@ export default function DBDeltaChart({ const outlierCount = outlierValueOccurences.get(key) ?? new Map(); - // Use proportional comparison scoring which normalizes group sizes - const sortScore = computeComparisonScore(outlierCount, inlierCount); + // Use proportional comparison scoring which normalizes group sizes. + // Semantic boost acts as a tiebreaker for well-known OTel attributes + // (only applied when the field has actual variance). + const baseScore = computeComparisonScore(outlierCount, inlierCount); + const boost = baseScore > 0 ? semanticBoost(key) * 0.1 : 0; + const sortScore = baseScore + boost; return [key, sortScore] as const; }) diff --git a/packages/app/src/components/__tests__/deltaChartScoring.test.ts b/packages/app/src/components/__tests__/deltaChartScoring.test.ts index a311e8baf7..22e7254034 100644 --- a/packages/app/src/components/__tests__/deltaChartScoring.test.ts +++ b/packages/app/src/components/__tests__/deltaChartScoring.test.ts @@ -33,9 +33,17 @@ describe('computeComparisonScore', () => { expect(computeComparisonScore(outlier, inlier)).toBeGreaterThan(70); }); - it('uses raw delta when one group has no data', () => { + it('normalizes to [0, 100] when one group has no data', () => { + // Single-value field: 100% of the present group → max normalized pct = 100 const outlier = new Map([['error', 50]]); - expect(computeComparisonScore(outlier, new Map())).toBe(50); + expect(computeComparisonScore(outlier, new Map())).toBe(100); + + // Two-value field: 80/20 split → max normalized pct = 80 + const outlier2 = new Map([ + ['error', 80], + ['ok', 20], + ]); + expect(computeComparisonScore(outlier2, new Map())).toBe(80); }); it('normalizes by group sum so different sample sizes produce same score', () => { diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index 83585a8c0d..f69143c800 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -347,14 +347,16 @@ export function computeComparisonScore( if (outlierSum === 0 && inlierSum === 0) return 0; if (outlierSum === 0 || inlierSum === 0) { - let maxDelta = 0; - allValues.forEach(value => { - const delta = Math.abs( - (outlierValues.get(value) ?? 0) - (inlierValues.get(value) ?? 0), - ); - if (delta > maxDelta) maxDelta = delta; + // One group has data, the other doesn't — normalize the present group + // to [0, 100] so the score is scale-consistent with the two-group case. + const presentValues = outlierSum > 0 ? outlierValues : inlierValues; + const presentSum = outlierSum > 0 ? outlierSum : inlierSum; + let maxNormPct = 0; + presentValues.forEach(v => { + const pct = (v / presentSum) * 100; + if (pct > maxNormPct) maxNormPct = pct; }); - return maxDelta; + return maxNormPct; } let maxDelta = 0; From dc39ce2ea08bfba9c460635649ee45fffeece602 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Fri, 6 Mar 2026 08:15:58 -0800 Subject: [PATCH 4/5] fix: score single-value fields with no comparison group as 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When one group is empty and the present group has only 1 unique value (e.g., Events.Name[N] = "message" at 100%), there's no information gain — score it 0 so it sinks below multi-value fields. Multi-value fields with no comparison group still get their normalized score since they show a distinctive distribution. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/deltaChartScoring.test.ts | 15 +++++++++------ packages/app/src/components/deltaChartUtils.ts | 9 +++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/__tests__/deltaChartScoring.test.ts b/packages/app/src/components/__tests__/deltaChartScoring.test.ts index 22e7254034..090823503b 100644 --- a/packages/app/src/components/__tests__/deltaChartScoring.test.ts +++ b/packages/app/src/components/__tests__/deltaChartScoring.test.ts @@ -33,17 +33,20 @@ describe('computeComparisonScore', () => { expect(computeComparisonScore(outlier, inlier)).toBeGreaterThan(70); }); - it('normalizes to [0, 100] when one group has no data', () => { - // Single-value field: 100% of the present group → max normalized pct = 100 + it('returns 0 for single-value field when other group is empty', () => { + // Single value with no comparison group is uninformative + // (e.g., Events.Name[N] = "message" at 100% with no inlier data) const outlier = new Map([['error', 50]]); - expect(computeComparisonScore(outlier, new Map())).toBe(100); + expect(computeComparisonScore(outlier, new Map())).toBe(0); + }); - // Two-value field: 80/20 split → max normalized pct = 80 - const outlier2 = new Map([ + it('normalizes multi-value field to [0, 100] when other group is empty', () => { + // Multi-value with no comparison group IS informative — shows distribution + const outlier = new Map([ ['error', 80], ['ok', 20], ]); - expect(computeComparisonScore(outlier2, new Map())).toBe(80); + expect(computeComparisonScore(outlier, new Map())).toBe(80); }); it('normalizes by group sum so different sample sizes produce same score', () => { diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index f69143c800..b96e14cf90 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -347,9 +347,14 @@ export function computeComparisonScore( if (outlierSum === 0 && inlierSum === 0) return 0; if (outlierSum === 0 || inlierSum === 0) { - // One group has data, the other doesn't — normalize the present group - // to [0, 100] so the score is scale-consistent with the two-group case. + // One group has data, the other doesn't. const presentValues = outlierSum > 0 ? outlierValues : inlierValues; + // Single value with no comparison group is uninformative — score 0. + // (e.g., Events.Name[N] = "message" at 100% with no inlier data) + // Multi-value fields with no comparison group ARE informative — they show + // that the present group has a distinctive distribution. + if (presentValues.size <= 1) return 0; + // Normalize to [0, 100] so the score is scale-consistent with the two-group case. const presentSum = outlierSum > 0 ? outlierSum : inlierSum; let maxNormPct = 0; presentValues.forEach(v => { From 96a1a8471ac54fc62fe96fe3ddeac414d643cacc Mon Sep 17 00:00:00 2001 From: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:59:45 -0700 Subject: [PATCH 5/5] fix: enforce dot-segment boundary in semanticBoost endsWith check Prevents false positives where keys like 'SpanAttributes.myerror' would incorrectly match the 'error' suffix entry. Now requires a dot separator before the suffix, or an exact match. Co-Authored-By: Claude Opus 4.6 --- packages/app/src/components/deltaChartUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index f810a36da4..21250cf021 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -503,12 +503,14 @@ const BOOSTED_ATTRIBUTE_SUFFIXES = [ /** * Returns 1 for well-known OTel attributes, 0 otherwise. + * Uses dot-segment boundary matching to avoid false positives + * (e.g., 'SpanAttributes.myerror' won't match the 'error' entry). * Callers scale this as a tiebreaker (e.g., * 0.1) and only apply when baseScore > 0. */ export function semanticBoost(key: string): number { const lowerKey = key.toLowerCase(); for (const suffix of BOOSTED_ATTRIBUTE_SUFFIXES) { - if (lowerKey.endsWith(suffix)) return 1; + if (lowerKey.endsWith('.' + suffix) || lowerKey === suffix) return 1; } return 0; }