sparkforensics-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/bin/sparkforensics-mcp.mjs +41 -10
  2. package/package.json +1 -1
  3. package/vendor-core/analyzer.js +156 -48
  4. package/vendor-core/check-coverage.js +88 -0
  5. package/vendor-core/cli/budgets.js +31 -18
  6. package/vendor-core/cli/collect-run.js +76 -31
  7. package/vendor-core/cli/threshold-config.js +28 -0
  8. package/vendor-core/comparison-verdict.js +177 -0
  9. package/vendor-core/core-source-hash.txt +1 -0
  10. package/vendor-core/core-usage-locality.js +56 -2
  11. package/vendor-core/detector-docs.js +58 -0
  12. package/vendor-core/detectors.js +918 -458
  13. package/vendor-core/docs-config.js +0 -36
  14. package/vendor-core/docs-content/chapters/nav-index.json +31 -0
  15. package/vendor-core/docs-content/detection/cstor.md +9 -0
  16. package/vendor-core/docs-site-config.js +3 -0
  17. package/vendor-core/event-handlers.js +170 -6
  18. package/vendor-core/event-schemas.js +21 -0
  19. package/vendor-core/evidence-report.js +421 -112
  20. package/vendor-core/export-data.js +79 -6
  21. package/vendor-core/finding-action-label.js +9 -88
  22. package/vendor-core/finding-filter-predicate.js +9 -0
  23. package/vendor-core/finding-generic-recommendation.js +6 -104
  24. package/vendor-core/finding-names.js +21 -45
  25. package/vendor-core/finding-presentation.js +333 -0
  26. package/vendor-core/finding-tag-help.js +110 -0
  27. package/vendor-core/finding-types.js +361 -0
  28. package/vendor-core/findings-of-type.js +11 -0
  29. package/vendor-core/format-utils.js +92 -27
  30. package/vendor-core/html-export.js +51 -0
  31. package/vendor-core/impact-band.js +21 -8
  32. package/vendor-core/impact-estimator.js +8 -521
  33. package/vendor-core/impact-format.js +114 -0
  34. package/vendor-core/impact-model.js +175 -0
  35. package/vendor-core/ingest.js +2 -0
  36. package/vendor-core/intervals.js +13 -0
  37. package/vendor-core/list-runs.js +2 -3
  38. package/vendor-core/load-vendored.js +70 -5
  39. package/vendor-core/mcp-server-factory.js +14 -10
  40. package/vendor-core/mcp-tools.js +105 -45
  41. package/vendor-core/model-assembler.js +12 -0
  42. package/vendor-core/occupancy.js +1 -1
  43. package/vendor-core/parser-worker.js +1 -1
  44. package/vendor-core/plan-graph-model.js +3 -2
  45. package/vendor-core/plan-node-detail.js +1 -1
  46. package/vendor-core/recommendation-rollup.js +63 -3
  47. package/vendor-core/redact.js +45 -27
  48. package/vendor-core/run-comparison.js +32 -7
  49. package/vendor-core/run-interpretation.js +290 -0
  50. package/vendor-core/run-outcome.js +74 -0
  51. package/vendor-core/run-payload.js +17 -0
  52. package/vendor-core/run-shape.js +40 -0
  53. package/vendor-core/run-verdict.js +353 -0
  54. package/vendor-core/scaling-sim.js +4 -5
  55. package/vendor-core/scorecard-estimates.js +62 -0
  56. package/vendor-core/sql-stages.js +11 -0
  57. package/vendor-core/stage-quantiles.js +2 -0
  58. package/vendor-core/threshold-overrides.js +160 -0
  59. package/vendor-core/threshold-summary.js +11 -33
  60. package/vendor-core/types.js +6 -42
  61. package/vendor-core/wall-clock.js +1 -12
  62. package/vendor-core/wasted-core-hours.js +2 -2
@@ -0,0 +1,361 @@
1
+ // Per-detector finding shapes. `Finding` is a union discriminated on `type`, one member per
2
+ // emitted finding type; `FindingType` (detectors.ts) derives the same set from `DETECTORS`' `emits`
3
+ // lists, and a compile-time check there keeps the two equal.
4
+ //
5
+ // Each member is split in two: `<Type>Evidence` is the public part, the fields the evidence report
6
+ // publishes as a finding row's `evidence` (EVIDENCE_KEYS in evidence-report.ts must list every one of
7
+ // them), and anything declared only on `<Type>Finding` is internal, read by another core module (the
8
+ // impact estimator, mostly) and never published. Renaming or removing an evidence field is a
9
+ // breaking change to the report and needs an EVIDENCE_SCHEMA_VERSION bump.
10
+
11
+
12
+
13
+ // The columns every finding carries, whatever its detector.
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+ /** One overridden threshold: the value the detector ran with, and its specification default. */
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+ // A finding whose `value` is a magnitude (a ratio, byte count, percentage, duration...). Absent on
43
+ // an evidence caveat, which has nothing to measure.
44
+
45
+
46
+
47
+
48
+
49
+ // A finding whose observed value is display text (a failure reason, an audited config's current
50
+ // value), carried in `valueText` so `value` stays numeric across every finding.
51
+
52
+
53
+
54
+
55
+
56
+ // A failed or retried task attempt, as a stage keeps a bounded sample of them.
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+ // ── Per-stage detectors ─────────────────────────────────────────────────────
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+ // Three shapes share this type: a slow host by mean task time (no variant), a host doing most of the
101
+ // stage's task time ('durationShare'), and an executor deviating on one dimension ('multiDim').
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+
124
+ // valueText is the stage's recorded failure reason.
125
+
126
+
127
+
128
+
129
+
130
+
131
+
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+ // ── App-level detectors ─────────────────────────────────────────────────────
162
+
163
+
164
+ // valueText is always 'missing': the ApplicationEnd event the log lacks.
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
185
+
186
+
187
+
188
+
189
+
190
+
191
+
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+
200
+
201
+
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+
210
+
211
+
212
+
213
+
214
+
215
+
216
+
217
+
218
+
219
+
220
+
221
+
222
+
223
+
224
+
225
+ // A relation scanned by several SQL executions (no variant), or a join/union result several
226
+ // executions recompute ('composite', which lists its leaf relations).
227
+
228
+
229
+
230
+
231
+
232
+
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+
243
+
244
+
245
+
246
+
247
+
248
+
249
+ // ── Config-scope detectors ──────────────────────────────────────────────────
250
+
251
+
252
+
253
+
254
+ // valueText is the audited property's current value, or a note that it is unset or inverted.
255
+
256
+
257
+ // ── SQL-scope (Plan Advisor) detectors ──────────────────────────────────────
258
+
259
+
260
+
261
+
262
+
263
+ // View-layer plan-graph node ids the finding came from (plan-graph-model.ts). An array: a
264
+ // duplicate subtree spans many nodes, and a broadcast finding can involve more than one.
265
+
266
+
267
+
268
+
269
+
270
+
271
+
272
+
273
+
274
+
275
+
276
+
277
+
278
+
279
+
280
+
281
+
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+
293
+
294
+
295
+
296
+ // ── Unions ──────────────────────────────────────────────────────────────────
297
+
298
+ /** Public evidence fields per finding type: what a report row's `evidence` carries. */
299
+
300
+
301
+
302
+
303
+
304
+
305
+
306
+
307
+
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+
316
+
317
+
318
+
319
+
320
+
321
+
322
+
323
+
324
+
325
+
326
+
327
+
328
+
329
+
330
+
331
+
332
+
333
+
334
+
335
+
336
+
337
+
338
+
339
+
340
+
341
+
342
+
343
+
344
+
345
+
346
+
347
+
348
+
349
+
350
+
351
+
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+
360
+ /** The finding member for one `type`. */
361
+
@@ -0,0 +1,11 @@
1
+
2
+
3
+ /** The findings of one `type`, typed as that type's finding member. */
4
+ export function findingsOfType (findings , type ) {
5
+ return findings.filter((f) => f.type === type);
6
+ }
7
+
8
+ /** A sql-scope finding's linked stages; undefined for every finding that keys on `stageId`. */
9
+ export function findingStageIds(f ) {
10
+ return 'stageIds' in f ? f.stageIds : undefined;
11
+ }
@@ -1,3 +1,6 @@
1
+ import { FINDING_PRESENTATION } from './finding-presentation.js';
2
+
3
+
1
4
  const TARGET_PARTITION_BYTES = 128 * 1024 * 1024;
2
5
  const MAX_RECOMMENDED = 8000;
3
6
  const MEANINGFUL_RATIO = 1.5;
@@ -5,11 +8,11 @@ const MEANINGFUL_RATIO = 1.5;
5
8
  export const VISIBLE_LIMIT = 6;
6
9
  export const IMPACT_BAND_ORDER = { critical: 0, warning: 1, info: 2 };
7
10
 
8
- const BOTTLENECK_WIDGET = {
11
+ const BOTTLENECK_WIDGET = {
9
12
  skew: 'task-skew', slowHost: 'task-skew', straggler: 'task-skew',
10
13
  shuffle: 'shuffle-io', spill: 'spill', gc: 'gc-pressure', failures: 'failures',
11
14
  coldStart: 'executor-timeline', utilization: 'executor-timeline', speculationWaste: 'executor-timeline',
12
- };
15
+ } ;
13
16
 
14
17
  // Cross-widget stage recurrence: how many distinct board widgets (skew+straggler on one stage
15
18
  // still count as one, task-skew) flag a stage. Computed once from the full catalog each widget
@@ -30,22 +33,12 @@ export function stageWidgetFrequency(catalog
30
33
  return freq;
31
34
  }
32
35
 
33
- // Canonical detector type -> ALL-CAPS board tag vocabulary. Single source of truth for every
34
- // widget that renders a catalog entry's tag outside its own card (e.g. Bottleneck Alerts).
35
- // Exported so doc-sync checks can enumerate every tag without hand-duplicating this list.
36
- export const TYPE_TAG_MAP = {
37
- skew: 'SKEW', shuffle: 'SHFL', spill: 'SPILL', gc: 'GC',
38
- coldStart: 'COLD', utilization: 'UTIL', memoryUtilization: 'MEM',
39
- cacheUtilization: 'CSTOR', coreLocality: 'LOCAL', autoscalingChurn: 'CHRN',
40
- slowHost: 'HOST', failures: 'FAIL', straggler: 'STRAG',
41
- retryWaste: 'RETRY', tinyTask: 'TINY', stageFailed: 'SFAIL',
42
- speculationWaste: 'SPEC',
43
- partitionSizing: 'PART', stageSlowness: 'SLOW', stageShape: 'SHAPE',
44
- cachingOpportunity: 'CACHE', jobFailureRate: 'JOBS', configAudit: 'CFG',
45
- duplicatePlanSubtree: 'PLAN', smallFiles: 'PLAN', underBroadcast: 'PLAN', overBroadcast: 'PLAN',
46
- broadcastSizing: 'PLAN',
47
- incompleteRun: 'INCMP',
48
- };
36
+ // Finding type -> ALL-CAPS board tag, derived from FINDING_PRESENTATION. Every widget that renders
37
+ // a catalog entry's tag outside its own card (e.g. Bottleneck Alerts) reads it through typeTag.
38
+ // Exported so doc-sync checks can enumerate every tag. Read by free-form type string.
39
+ export const TYPE_TAG_MAP = Object.fromEntries(
40
+ (Object.keys(FINDING_PRESENTATION) ).map((type) => [type, FINDING_PRESENTATION[type].tag]),
41
+ );
49
42
 
50
43
  export function typeTag(type ) {
51
44
  return TYPE_TAG_MAP[type] ?? type.toUpperCase();
@@ -134,10 +127,10 @@ export function nsToMs(ns ) {
134
127
  return ns / 1e6;
135
128
  }
136
129
 
137
- // Finding.value is number|string only because a couple of detectors (configAudit, stageFailed)
138
- // report a string; every other row formatter needs the numeric case and falls back to 0.
139
- export function numericValue(f ) {
140
- return typeof f.value === 'number' ? f.value : 0;
130
+ // A finding's magnitude, or 0 when it has none (an evidence caveat, or a text-valued finding whose
131
+ // value is in `valueText`).
132
+ export function numericValue(f ) {
133
+ return f.value ?? 0;
141
134
  }
142
135
 
143
136
  // Low-level "number -> display string" step shared by every widget's per-finding label resolver:
@@ -181,11 +174,11 @@ function formatChipBytes(bytes ) {
181
174
  }
182
175
 
183
176
  /** Compact magnitude for a finding's plan-graph chip (e.g. "4.2 GB", "3.2×", "45%"), taken from the
184
- * finding's own `value` rendered in its `metric`'s unit. Returns null when `value` is non-numeric
185
- * (stageFailed/configAudit reuse it for text) or the metric isn't in FINDING_METRIC_UNIT. */
186
- export function formatFindingMagnitude(finding ) {
177
+ * finding's own `value` rendered in its `metric`'s unit. Returns null when there is no `value` (a
178
+ * text-valued finding carries `valueText` instead) or the metric isn't in FINDING_METRIC_UNIT. */
179
+ export function formatFindingMagnitude(finding ) {
187
180
  const unit = finding.metric ? FINDING_METRIC_UNIT[finding.metric] : undefined;
188
- if (!unit || typeof finding.value !== 'number') return null;
181
+ if (!unit || finding.value == null) return null;
189
182
  switch (unit) {
190
183
  case 'bytes': return formatChipBytes(finding.value);
191
184
  case 'minutes': return formatDuration(finding.value * 60000);
@@ -201,7 +194,7 @@ export function formatFindingMagnitude(finding
201
194
  * recovery is claimed, "~<time>" (the optimistic `high` bound, matching formatImpactEstimateCompact),
202
195
  * joined by " · " (e.g. "4.2 GB · ~38.0s"). null when neither part is available. */
203
196
  export function formatFindingChipDetail(
204
- finding ,
197
+ finding ,
205
198
  ) {
206
199
  const magnitude = formatFindingMagnitude(finding);
207
200
  const wallClock = finding.impactEstimate?.wallClock;
@@ -239,3 +232,75 @@ export function buildHistogram(values , bins )
239
232
  const labels = counts.map((_, i) => Math.round(min + i * binSize));
240
233
  return { labels, data: counts };
241
234
  }
235
+
236
+ // Unit formatting for savings and resource figures, shared by the interpretation and by
237
+ // renderers that total a filtered selection themselves (the recommendation rollup).
238
+ export function fmtMs(ms ) {
239
+ return ms === 0 ? '0s' : formatDuration(ms);
240
+ }
241
+
242
+ export function formatWallClockRange(low , high ) {
243
+ const lowText = fmtMs(low);
244
+ const highText = fmtMs(high);
245
+ // Compare the formatted strings, not the raw ms values: formatDuration
246
+ // floors to whole seconds (or minutes+seconds) once a value hits 60s, so
247
+ // two endpoints that differ by under a bucket's precision (e.g. 140900ms
248
+ // vs 141200ms, both "2m 21s") would still fail a raw low === high check
249
+ // and render as a degenerate "2m 21s-2m 21s" range.
250
+ if (lowText === highText) return highText;
251
+ return `${lowText}-${highText}`;
252
+ }
253
+
254
+ const MS_PER_HOUR = 3_600_000;
255
+ const MB_SECONDS_PER_GB_HOUR = 1024 * 3600;
256
+ /** Below this many hours a figure keeps its small unit, so "0.0 GB-h" never
257
+ * hides a real but small amount. */
258
+ const MIN_HOURS_SHOWN = 0.1;
259
+
260
+ const oneDecimal = (value ) =>
261
+ (Math.round(value * 10) / 10).toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
262
+
263
+ export function formatRawWaste(rawWaste ) {
264
+ const rounded = Math.round(rawWaste.value * 10) / 10;
265
+ switch (rawWaste.unit) {
266
+ case 'bytes':
267
+ return formatBytes(rawWaste.value);
268
+ case 'ms':
269
+ return fmtMs(rawWaste.value);
270
+ case 'mbSeconds': {
271
+ // A whole run's idle memory reaches millions of MB-seconds; GB-hours
272
+ // keeps it a number a reader can compare.
273
+ const gbHours = rawWaste.value / MB_SECONDS_PER_GB_HOUR;
274
+ return gbHours >= MIN_HOURS_SHOWN ? `${oneDecimal(gbHours)} GB-h` : `${rounded.toLocaleString('en-US')} MB-s`;
275
+ }
276
+ case 'coreHours':
277
+ return `${rounded.toFixed(1)} core-h`;
278
+ case 'coreMs': {
279
+ const coreHours = rawWaste.value / MS_PER_HOUR;
280
+ return coreHours >= MIN_HOURS_SHOWN ? `${oneDecimal(coreHours)} core-h` : `${oneDecimal(rawWaste.value / 1000)} core-s`;
281
+ }
282
+ default:
283
+ return String(rawWaste.value);
284
+ }
285
+ }
286
+
287
+ // formatRawWaste/fmtMs round to a fixed precision (one decimal place, or
288
+ // whole milliseconds below 1s), which can collapse a small but genuinely
289
+ // nonzero value down to a formatted string that reads exactly like zero
290
+ // (0.04 coreHours -> "0.0 core-h"). Checking the raw value against 0 misses
291
+ // this; checking the *formatted* text against zero's own formatted text
292
+ // catches it regardless of unit. The fractional group must consume the
293
+ // entire decimal part (all zeros) before the terminator: otherwise a real
294
+ // value like "0.5 core-h" leaves the "." unconsumed, and the terminator
295
+ // char class excludes ".", so it correctly fails to match.
296
+ export function readsAsZero(formatted ) {
297
+ return /^0(\.0+)?(?:[^0-9.]|$)/.test(formatted);
298
+ }
299
+
300
+ /** Whole cores from 10 up; one decimal below, so a small run's peak never
301
+ * rounds down to "0 cores". */
302
+ export function formatCores(cores ) {
303
+ return cores >= 10 ? String(Math.round(cores)) : cores.toFixed(1).replace(/\.0$/, '');
304
+ }
305
+
306
+ export const MS_PER_CORE_HOUR = 3.6e6;
@@ -0,0 +1,51 @@
1
+ import { auditConfig } from './analyzer.js';
2
+ import { buildExportRunData, CORE_VERSION, } from './export-data.js';
3
+ import { redactRunModel } from './redact.js';
4
+ import { interpretRun } from './run-interpretation.js';
5
+ import { gzipSync, strToU8 } from './vendor/fflate.js';
6
+
7
+
8
+ // Shared by the two producers of the self-contained HTML dashboard: the CLI's
9
+ // --export-html (writeHtmlExport, which writes a data.js next to the template)
10
+ // and the dashboard's "Download HTML" item (EvidenceExport.tsx, which inlines
11
+ // the same statement into one downloaded file). Both encode through
12
+ // encodeRunPayload here and wrap it with runPayloadScript (run-payload.ts); the
13
+ // export app's decodeRunPayload (src/export/hydrate-store.ts) reverses it.
14
+
15
+ /** The run data an HTML export carries: the serialized model, the config
16
+ * audit, and the whole interpretation layer (verdict, coverage, formatted
17
+ * savings, run shape) computed here, so the bundle that opens the file renders
18
+ * this core's conclusions instead of deriving its own. With `redact`, the model
19
+ * and findings are pseudonymized first and the redacted run is interpreted, so
20
+ * text the interpretation truncates (Spark's failure reason) can never keep
21
+ * part of an identifier the redactor would no longer recognize. */
22
+ export function buildHtmlExportData(
23
+ appModel ,
24
+ catalog ,
25
+ skippedLines ,
26
+ { redact, buildId, producer } ,
27
+ ) {
28
+ const raw = { appModel, catalog, configFindings: auditConfig(appModel.app) };
29
+ const run = redact ? redactRunModel(raw.appModel, raw.catalog, raw.configFindings) : raw;
30
+ const interpretation = interpretRun(run.appModel, run.catalog, run.configFindings);
31
+ const provenance = { coreVersion: CORE_VERSION, buildId, producer };
32
+ return buildExportRunData(run.appModel, run.catalog, run.configFindings, skippedLines, interpretation, provenance);
33
+ }
34
+
35
+ // String.fromCharCode spreads its arguments onto the stack; a multi-MB payload
36
+ // in one call overflows it, so convert in slices.
37
+ const BASE64_CHUNK_BYTES = 0x8000;
38
+
39
+ function bytesToBase64(bytes ) {
40
+ let binary = '';
41
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK_BYTES) {
42
+ binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK_BYTES));
43
+ }
44
+ return btoa(binary);
45
+ }
46
+
47
+ /** gzip + base64 of the JSON payload, runnable in the browser (fflate, no
48
+ * node:zlib). strToU8 encodes UTF-8, matching decodeRunPayload's decode. */
49
+ export function encodeRunPayload(data ) {
50
+ return bytesToBase64(gzipSync(strToU8(JSON.stringify(data))));
51
+ }
@@ -1,12 +1,19 @@
1
1
 
2
- import { STRAGGLER_FLOOR_PCT_WARN, STRAGGLER_FLOOR_PCT_CRIT } from './detectors.js';
3
2
 
4
- // Reused from straggler's own thresholds (marked NOT SOURCED: unvalidated there): apply the same
5
- // accepted noise floor globally rather than inventing a second cutoff. Imported so they can't drift.
6
- const IMPACT_FLOOR_PCT_WARN = STRAGGLER_FLOOR_PCT_WARN;
7
- const IMPACT_FLOOR_PCT_CRIT = STRAGGLER_FLOOR_PCT_CRIT;
3
+ // The run-wide noise floor, as a share of the run's duration, that grades every wall-clock
4
+ // estimate (NOT SOURCED: our own, unvalidated). skew and straggler default their firing floors to
5
+ // these same figures (detectors.ts), so a finding they admit grades at least warning here.
6
+ export const IMPACT_FLOOR_PCT_WARN = 0.005;
7
+ export const IMPACT_FLOOR_PCT_CRIT = 0.02;
8
8
 
9
- function appDurationMs(app ) {
9
+ /** The share-of-run floors one finding grades against. */
10
+
11
+
12
+ const DEFAULT_FLOORS = { warnPct: IMPACT_FLOOR_PCT_WARN, critPct: IMPACT_FLOOR_PCT_CRIT };
13
+
14
+ /** The run's wall-clock duration, or null when unknown or non-positive: the denominator of both
15
+ * this band and the detectors' runtime floors. */
16
+ export function appDurationMs(app ) {
10
17
  if (app?.startTime == null || app?.endTime == null) return null;
11
18
  const durationMs = app.endTime - app.startTime;
12
19
  return durationMs > 0 ? durationMs : null;
@@ -23,8 +30,13 @@ function appDurationMs(app ) {
23
30
  * figure), a deliberate split from view/impact-sort.ts's 'impact' sort, which ranks by .low so an
24
31
  * optimistic-but-contended finding never outranks a smaller certain one. Same range, two fields for
25
32
  * two questions.
33
+ *
34
+ * `floorsFor` supplies a finding type's floors when a tuned run moved them (skew's and straggler's
35
+ * own floorPctWarn/floorPctCrit); omitted, or for any type it returns nothing, the defaults above.
26
36
  */
27
- export function deriveImpactBand(findings , app ) {
37
+ export function deriveImpactBand(
38
+ findings , app , floorsFor ,
39
+ ) {
28
40
  const durationMs = appDurationMs(app);
29
41
  if (durationMs == null) return findings;
30
42
  for (const finding of findings) {
@@ -38,7 +50,8 @@ export function deriveImpactBand(findings , app )
38
50
  const recoverableMs = finding.impactEstimate?.wallClock?.high;
39
51
  if (recoverableMs == null) continue;
40
52
  const pct = recoverableMs / durationMs;
41
- finding.impactBand = pct >= IMPACT_FLOOR_PCT_CRIT ? 'critical' : pct >= IMPACT_FLOOR_PCT_WARN ? 'warning' : 'info';
53
+ const { warnPct, critPct } = floorsFor?.(finding.type) ?? DEFAULT_FLOORS;
54
+ finding.impactBand = pct >= critPct ? 'critical' : pct >= warnPct ? 'warning' : 'info';
42
55
  }
43
56
  return findings;
44
57
  }