sparkforensics-cli 0.1.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 (60) hide show
  1. package/bin/sparkforensics-analyze.mjs +288 -0
  2. package/package.json +29 -0
  3. package/vendor-core/analyzer.js +167 -0
  4. package/vendor-core/assert-never.js +3 -0
  5. package/vendor-core/cli/budgets.js +203 -0
  6. package/vendor-core/cli/collect-run.js +107 -0
  7. package/vendor-core/core-count.js +68 -0
  8. package/vendor-core/core-locality-ratio.js +54 -0
  9. package/vendor-core/core-time-series.js +92 -0
  10. package/vendor-core/core-usage-locality.js +70 -0
  11. package/vendor-core/detectors.js +1989 -0
  12. package/vendor-core/docs-config.js +72 -0
  13. package/vendor-core/docs-site-config.js +23 -0
  14. package/vendor-core/efficiency-model.js +62 -0
  15. package/vendor-core/etl-phases.js +28 -0
  16. package/vendor-core/event-handlers.js +906 -0
  17. package/vendor-core/event-schemas.js +405 -0
  18. package/vendor-core/evidence-availability.js +121 -0
  19. package/vendor-core/evidence-report.js +459 -0
  20. package/vendor-core/finding-action-label.js +97 -0
  21. package/vendor-core/finding-filter-predicate.js +38 -0
  22. package/vendor-core/format-utils.js +167 -0
  23. package/vendor-core/impact-band.js +50 -0
  24. package/vendor-core/impact-estimator.js +428 -0
  25. package/vendor-core/ingest.js +139 -0
  26. package/vendor-core/job-groups.js +30 -0
  27. package/vendor-core/load-vendored.js +24 -0
  28. package/vendor-core/lz4-block.js +135 -0
  29. package/vendor-core/mcp-error.js +3 -0
  30. package/vendor-core/mcp-server-factory.js +115 -0
  31. package/vendor-core/mcp-tools.js +331 -0
  32. package/vendor-core/model-assembler.js +76 -0
  33. package/vendor-core/occupancy.js +202 -0
  34. package/vendor-core/parser-worker.js +249 -0
  35. package/vendor-core/plan-dot.js +25 -0
  36. package/vendor-core/plan-duration-attribution.js +185 -0
  37. package/vendor-core/plan-graph-model.js +171 -0
  38. package/vendor-core/plan-node-detail.js +159 -0
  39. package/vendor-core/plan-summary.js +233 -0
  40. package/vendor-core/plan-tree-walk.js +29 -0
  41. package/vendor-core/proxy.js +157 -0
  42. package/vendor-core/recommendation-rollup.js +197 -0
  43. package/vendor-core/redact.js +175 -0
  44. package/vendor-core/rolling-log-reassembly.js +52 -0
  45. package/vendor-core/run-aggregates.js +44 -0
  46. package/vendor-core/run-comparison.js +458 -0
  47. package/vendor-core/scaling-sim.js +73 -0
  48. package/vendor-core/session-snapshot.js +79 -0
  49. package/vendor-core/shs-fetch.js +196 -0
  50. package/vendor-core/shs-load.js +121 -0
  51. package/vendor-core/shs-request.js +101 -0
  52. package/vendor-core/shs-schemas.js +13 -0
  53. package/vendor-core/snappy-block.js +140 -0
  54. package/vendor-core/stage-quantiles.js +199 -0
  55. package/vendor-core/threshold-summary.js +35 -0
  56. package/vendor-core/types.js +286 -0
  57. package/vendor-core/vendor/fflate.js +2695 -0
  58. package/vendor-core/vendor/fzstd.js +768 -0
  59. package/vendor-core/wall-clock.js +36 -0
  60. package/vendor-core/wasted-core-hours.js +68 -0
@@ -0,0 +1,458 @@
1
+ // src/run-comparison.ts
2
+ import { computeWallClock } from './wall-clock.js';
3
+ import { normalizeDetail } from './detectors.js';
4
+ import { captureSnapshot } from './session-snapshot.js';
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ // Replace run-varying tokens (digit runs, long hex ids) with a stable marker so
29
+ // the same logical stage across two runs normalizes to one identity.
30
+ export function normalizeStageName(name ) {
31
+ return String(name)
32
+ .toLowerCase()
33
+ .replace(/\b[0-9a-f]{8,}\b/g, '#') // hex ids/uuids first (they contain digits)
34
+ .replace(/\d+/g, '#')
35
+ .replace(/\s+/g, ' ')
36
+ .trim();
37
+ }
38
+
39
+ // cyrb53: fast, non-cryptographic 53-bit string hash, deterministic, portable
40
+ // (runs in-browser worker and Node CLI, no crypto module dependency), fixed-
41
+ // length hex output regardless of input size. src/analyzer.js already has a
42
+ // small stable-hash helper (`fnv1a`, 32-bit) for deterministic finding ids,
43
+ // but that runs once per finding; planTreeIdentity's `visit` below runs once
44
+ // per PLAN NODE, for every stage, in both runs of a comparison: orders of
45
+ // magnitude more hash calls, so the smaller 32-bit space's birthday-collision
46
+ // risk is a real concern here in a way it isn't for finding ids. 53 bits keeps
47
+ // collision probability negligible at that scale; adequate for a same-run
48
+ // identity key, not a security boundary.
49
+ function cyrb53(str , seed = 0) {
50
+ let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
51
+ for (let i = 0; i < str.length; i++) {
52
+ const ch = str.charCodeAt(i);
53
+ h1 = Math.imul(h1 ^ ch, 2654435761);
54
+ h2 = Math.imul(h2 ^ ch, 1597334677);
55
+ }
56
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
57
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
58
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
59
+ }
60
+
61
+ // Bottom-up, order-independent structural identity of a resolved plan tree:
62
+ // each node folds its own normalized name and normalized `detail` with its
63
+ // already-computed children's DIGESTS (children sorted, so sibling
64
+ // reordering across runs (e.g. AQE picking a different broadcast side)
65
+ // still matches), so two plans collide only when their whole shape AND every
66
+ // node's normalized detail agree. `normalizeDetail` (src/detectors.js, built
67
+ // for cachingOpportunity's composite join/union detection) strips expr ids,
68
+ // `plan_id=`, codegen-stage numbers, and AQE's BuildLeft/BuildRight choice,
69
+ // and canonicalizes commutative equality operands: exactly the run-to-run
70
+ // noise a cross-run identity needs to ignore. Replaces the previous
71
+ // flatten-and-sort-names identity, which ignored `detail` entirely (so a
72
+ // join on one column and a join on another, with identical operator names,
73
+ // collided) and ignored nesting (so two differently-shaped trees sharing a
74
+ // node-name multiset also collided).
75
+ //
76
+ // Each node is encoded via JSON.stringify of its [name, detail, childDigests]
77
+ // triple rather than an ad hoc delimiter-joined string: real Spark `detail`
78
+ // text routinely contains its own `<`/`>`/`{`/`}`/`,` characters (e.g.
79
+ // `ReadSchema: struct<id:bigint>`), so a hand-rolled delimiter scheme could
80
+ // let two structurally different plans serialize to the same string;
81
+ // JSON.stringify escapes/quotes each component so the encoding is
82
+ // unambiguous. Critically, that JSON payload is then folded down to a fixed-
83
+ // length `cyrb53` digest before being returned: the payload only ever
84
+ // contains a node's own (short) name/detail plus an array of already-hashed,
85
+ // fixed-length child digests, never a raw nested JSON blob. Embedding full
86
+ // child identity STRINGS instead (this function's first version) made each
87
+ // level re-stringify-and-escape the level below, so identity length grew
88
+ // ~2^depth; real Spark plans routinely nest 20-60+ operators deep
89
+ // (Scan→Filter→Project→HashAggregate→Exchange→Sort→Join, repeated per
90
+ // join/aggregate), which blew up to megabytes-per-stage or a
91
+ // `RangeError: Invalid string length` well within that range. Digest-folding
92
+ // keeps per-node output size bounded by name+detail length plus a small
93
+ // constant per child, independent of subtree depth.
94
+ function planTreeIdentity(root ) {
95
+ if (!root) return null;
96
+ function visit(node ) {
97
+ const childDigests = (node.children ?? []).map(visit).sort();
98
+ return cyrb53(JSON.stringify([normalizeStageName(node.name ?? ''), normalizeDetail(node.detail ?? ''), childDigests]));
99
+ }
100
+ return visit(root);
101
+ }
102
+
103
+ // Plan identity for the stage's SQL execution, so stage identity also requires
104
+ // the SQL-node identity to agree. Empty string when no SQL/plan.
105
+ function sqlNodeIdentity(stage , snapshot ) {
106
+ const execId = stage.sqlExecutionId;
107
+ if (execId == null) return '';
108
+ return planTreeIdentity(snapshot.sql.get(execId)?.planTree ?? null) ?? '';
109
+ }
110
+
111
+ export function stageIdentity(stage , snapshot ) {
112
+ return normalizeStageName(stage.name ?? '') + '§' + sqlNodeIdentity(stage, snapshot);
113
+ }
114
+
115
+ function identityIndex(snapshot ) {
116
+ const byIdentity = new Map (); // identity -> stageId[]
117
+ for (const [id, stage] of snapshot.stages) {
118
+ const key = stageIdentity(stage, snapshot);
119
+ const ids = byIdentity.get(key);
120
+ if (ids) ids.push(id);
121
+ else byIdentity.set(key, [id]);
122
+ }
123
+ return byIdentity;
124
+ }
125
+
126
+ export function matchStages(baseSnap , candSnap )
127
+
128
+
129
+
130
+
131
+ {
132
+ const baseIdx = identityIndex(baseSnap);
133
+ const candIdx = identityIndex(candSnap);
134
+ const pairs = [];
135
+ const matchedIdentities = new Set ();
136
+ const collisionIdentities = new Set ();
137
+ // A collision is a single-run property: any identity that more than one stage
138
+ // in the SAME run normalizes to. Record them per-run, independent of whether
139
+ // the other run shares that identity (a colliding stage with no counterpart
140
+ // still makes the run ambiguous).
141
+ for (const idx of [baseIdx, candIdx])
142
+ for (const [identity, ids] of idx) if (ids.length > 1) collisionIdentities.add(identity);
143
+ // Pair only identities that map to exactly one stage on BOTH sides.
144
+ // Deterministic order: sort identities lexically.
145
+ for (const identity of [...baseIdx.keys()].sort()) {
146
+ if (!candIdx.has(identity)) continue;
147
+ const baseIds = baseIdx.get(identity) ;
148
+ const candIds = candIdx.get(identity) ;
149
+ if (baseIds.length > 1 || candIds.length > 1) continue; // collision already recorded above
150
+ pairs.push({ identity, baseId: baseIds[0], candId: candIds[0] });
151
+ matchedIdentities.add(identity);
152
+ }
153
+ const total = baseSnap.stages.size + candSnap.stages.size;
154
+ const coverage = total === 0 ? 0 : (2 * pairs.length) / total;
155
+ return { pairs, matchedIdentities, collisionIdentities, coverage };
156
+ }
157
+
158
+ function p95(values ) {
159
+ if (values.length === 0) return null;
160
+ const sorted = [...values].sort((a, b) => a - b);
161
+ const rank = Math.ceil(0.95 * sorted.length) - 1; // nearest-rank, deterministic
162
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank))];
163
+ }
164
+
165
+ // The stage numeric fields sumField/perStageMetrics read; keeps the generic
166
+ // `field` parameter narrowed to a known numeric Stage property instead of
167
+ // widening to `string` (Stage also carries a `[key: string]: unknown` index
168
+ // signature for detector-only fields).
169
+
170
+
171
+
172
+
173
+ // Sum a numeric stage field over a stage list; `present` is false when no stage
174
+ // carried a finite value (so the metric renders Unavailable rather than a false 0).
175
+ function sumField(stages , field ) {
176
+ let sum = 0, present = false;
177
+ for (const s of stages) {
178
+ const v = s[field];
179
+ if (Number.isFinite(v)) { sum += v ; present = true; }
180
+ }
181
+ return { sum, present };
182
+ }
183
+
184
+ // Shared by skewRatios (whole-run p95 input) and stageSkewDeltas' `ratio`
185
+ // closure (per-pair matched comparison): both need the identical per-stage
186
+ // task-skew formula (max task duration over stage wall-clock duration).
187
+ function stageSkewRatio(stage ) {
188
+ const dur = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
189
+ return dur > 0 && Number.isFinite(stage.taskDurationMax) ? (stage.taskDurationMax ) / dur : null;
190
+ }
191
+
192
+ function skewRatios(stages ) {
193
+ const out = [];
194
+ for (const s of stages) {
195
+ const ratio = stageSkewRatio(s);
196
+ if (ratio != null) out.push(ratio);
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // Volume/count metrics, not cost metrics: more or less input/output data, or
202
+ // tasks/executors, isn't inherently better or worse (it may just reflect a
203
+ // differently-sized job), unlike wall-clock, spill, GC, etc. Exported as the
204
+ // single source of truth for "which metrics have no better/worse direction",
205
+ // shared by the view (RunComparison.tsx, for Δ coloring) and by
206
+ // src/cli/budgets.ts's checkRegression (so a `--regression-metric inputBytes`
207
+ // budget can't misread "processed more data" as a regression).
208
+ export const NEUTRAL_METRIC_KEYS = new Set(['inputBytes', 'outputBytes', 'taskCount', 'executorsAdded']);
209
+
210
+ function direction(key , baseline , candidate ) {
211
+ if (baseline == null || candidate == null) return 'unavailable';
212
+ if (NEUTRAL_METRIC_KEYS.has(key)) return candidate === baseline ? 'unchanged' : 'neutral';
213
+ const d = candidate - baseline;
214
+ return d < 0 ? 'improvement' : d > 0 ? 'regression' : 'unchanged';
215
+ }
216
+
217
+ function metric(
218
+ key , label , baseline , candidate ,
219
+ extra = {},
220
+ ) {
221
+ const dir = direction(key, baseline, candidate);
222
+ const delta = baseline == null || candidate == null ? null : candidate - baseline;
223
+ return { key, label, baseline, candidate, delta, direction: dir, ...extra };
224
+ }
225
+
226
+ export function metricDeltas(baseSnap , candSnap ) {
227
+ const out = [];
228
+
229
+ // Wall-clock: always computable (computeWallClock tolerates a null app).
230
+ out.push(metric('wallClock', 'Wall-clock duration',
231
+ computeWallClock(baseSnap.app, baseSnap.stages).total,
232
+ computeWallClock(candSnap.app, candSnap.stages).total));
233
+
234
+ // Shuffle spill over the whole run: a sum needs no stage matching, and
235
+ // matching is unreliable on real logs (see plan rationale), so scope it to
236
+ // all stages exactly like task-skew and failed-rate below.
237
+ const bSpill = sumField([...baseSnap.stages.values()], 'memoryBytesSpilled');
238
+ const cSpill = sumField([...candSnap.stages.values()], 'memoryBytesSpilled');
239
+ out.push(metric('shuffleSpill', 'Shuffle spill',
240
+ bSpill.present ? bSpill.sum : null, cSpill.present ? cSpill.sum : null,
241
+ { unavailableReason: bSpill.present && cSpill.present ? undefined : 'No shuffle-spill data recorded for a run' }));
242
+
243
+ // Task skew: p95 of per-stage ratio across the whole run.
244
+ const bSkew = p95(skewRatios([...baseSnap.stages.values()]));
245
+ const cSkew = p95(skewRatios([...candSnap.stages.values()]));
246
+ out.push(metric('taskSkew', 'Task skew (p95)', bSkew, cSkew,
247
+ { unavailableReason: bSkew != null && cSkew != null ? undefined : 'No stage had measurable duration for a run' }));
248
+
249
+ // Failed-task rate: Σ failedTasks / Σ taskCount.
250
+ const bTasks = sumField([...baseSnap.stages.values()], 'taskCount');
251
+ const cTasks = sumField([...candSnap.stages.values()], 'taskCount');
252
+ const bFailed = sumField([...baseSnap.stages.values()], 'failedTasks');
253
+ const cFailed = sumField([...candSnap.stages.values()], 'failedTasks');
254
+ // Guard on BOTH inputs: a missing `failedTasks` field must render Unavailable,
255
+ // not a false 0% rate (dividing an absent-and-therefore-0 numerator).
256
+ const bRate = bTasks.present && bTasks.sum > 0 && bFailed.present ? bFailed.sum / bTasks.sum : null;
257
+ const cRate = cTasks.present && cTasks.sum > 0 && cFailed.present ? cFailed.sum / cTasks.sum : null;
258
+ out.push(metric('failedTaskRate', 'Failed-task rate', bRate, cRate,
259
+ { unavailableReason: bRate != null && cRate != null ? undefined : 'No task or failure counts recorded for a run' }));
260
+
261
+ // Additional whole-run aggregates: plain sums of fields the stage already
262
+ // carries (set in finalizeStage). Correct at any match coverage, like the
263
+ // sums above; no parser or detector change.
264
+ const sumMetric = (key , label , field , reason ) => {
265
+ const b = sumField([...baseSnap.stages.values()], field);
266
+ const c = sumField([...candSnap.stages.values()], field);
267
+ out.push(metric(key, label, b.present ? b.sum : null, c.present ? c.sum : null,
268
+ { unavailableReason: b.present && c.present ? undefined : reason }));
269
+ };
270
+ sumMetric('diskSpill', 'Disk spill', 'diskBytesSpilled', 'No disk-spill data recorded for a run');
271
+ sumMetric('gcTime', 'GC time', 'jvmGCTime', 'No GC-time data recorded for a run');
272
+ sumMetric('inputBytes', 'Input read', 'inputBytes', 'No input-bytes data recorded for a run');
273
+ sumMetric('outputBytes', 'Output written', 'outputBytes', 'No output-bytes data recorded for a run');
274
+ sumMetric('executorRunTime', 'Executor run-time', 'executorRunTime', 'No executor run-time recorded for a run');
275
+ sumMetric('taskCount', 'Task count', 'taskCount', 'No task counts recorded for a run');
276
+
277
+ // Executor count is app-level, not per-stage. `executors` is absent on
278
+ // hand-built snapshots; guard so it renders Unavailable, not a crash.
279
+ const execCount = (snap ) =>
280
+ (Array.isArray(snap.executors?.added) ? snap.executors.added.length : null);
281
+ const bExec = execCount(baseSnap), cExec = execCount(candSnap);
282
+ out.push(metric('executorsAdded', 'Executors added', bExec, cExec,
283
+ { unavailableReason: bExec != null && cExec != null ? undefined : 'No executor events recorded for a run' }));
284
+
285
+ return out;
286
+ }
287
+
288
+ // Finding categories are counted, not matched: a (rule × impact band) tally needs
289
+ // no cross-run stage identity, so it stays correct even when almost no stages
290
+ // match uniquely (the common case on real logs, see plan rationale). A
291
+ // category the candidate has more of is "introduced"; fewer, "resolved".
292
+ export function findingsDelta(baseSnap , candSnap )
293
+
294
+ {
295
+
296
+ const tally = (snap ) => {
297
+ const m = new Map (); // `${rule}§${impactBand}` -> { rule, impactBand, count, stages:Set }
298
+ for (const f of snap.catalog) {
299
+ const rule = typeof f.rule === 'string' ? f.rule : f.type;
300
+ const impactBand = f.impactBand ?? 'unknown';
301
+ const key = `${rule}§${impactBand}`;
302
+ const e = m.get(key) ?? { rule, impactBand, count: 0, stages: new Set () };
303
+ e.count++;
304
+ // Resolve stageId → name on this snapshot only: a single-side lookup, so
305
+ // it needs no cross-run identity. App-level findings (stageId null) add none.
306
+ const stage = f.stageId != null ? snap.stages.get(f.stageId) : null;
307
+ if (stage?.name) e.stages.add(stage.name);
308
+ m.set(key, e);
309
+ }
310
+ return m;
311
+ };
312
+ const b = tally(baseSnap), c = tally(candSnap);
313
+ const introduced = [], resolved = [];
314
+ for (const key of [...new Set([...b.keys(), ...c.keys()])].sort()) {
315
+ const be = b.get(key), ce = c.get(key);
316
+ const baseCount = be?.count ?? 0;
317
+ const candCount = ce?.count ?? 0;
318
+ if (candCount === baseCount) continue;
319
+ const meta = ce ?? be ;
320
+ const more = candCount > baseCount ? ce : be ; // the side with more supplies the labels
321
+ const row = { rule: meta.rule, impactBand: meta.impactBand, baseCount, candCount,
322
+ delta: candCount - baseCount, stages: [...more.stages].sort() };
323
+ (candCount > baseCount ? introduced : resolved).push(row);
324
+ }
325
+ return { introduced, resolved };
326
+ }
327
+
328
+ function namesConflict(a , b ) {
329
+ return a?.name != null && b?.name != null && a.name !== b.name;
330
+ }
331
+
332
+ // A pair's skew ratio is null when the stage's duration wasn't measurable
333
+ // (see `ratio` below), matching CompareRunsResult['stageSkew']'s nullable
334
+ // baseline/candidate/delta fields.
335
+ function stageSkewDeltas(
336
+ baseSnap ,
337
+ candSnap ,
338
+ match ,
339
+ ) {
340
+ return match.pairs.map((p) => {
341
+ const b = stageSkewRatio(baseSnap.stages.get(p.baseId) );
342
+ const c = stageSkewRatio(candSnap.stages.get(p.candId) );
343
+ return { identity: p.identity, baseline: b, candidate: c, delta: b != null && c != null ? c - b : null };
344
+ }).sort((x, y) => x.identity < y.identity ? -1 : x.identity > y.identity ? 1 : 0);
345
+ }
346
+
347
+ // Compact, view-friendly per-stage record for the manual stage-pinning panel.
348
+ // Every field is number | null; a null means the stage did not carry it.
349
+ // Exported: it's CompareRunsResult['baseStages'/'candStages']'s metrics type.
350
+
351
+
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+
360
+
361
+
362
+ function perStageMetrics(stage ) {
363
+ const num = (v ) => (Number.isFinite(v) ? (v ) : null);
364
+ const dur = (stage.completedAt ?? 0) - (stage.submittedAt ?? 0);
365
+ return {
366
+ duration: dur > 0 ? dur : null,
367
+ memoryBytesSpilled: num(stage.memoryBytesSpilled),
368
+ diskBytesSpilled: num(stage.diskBytesSpilled),
369
+ jvmGCTime: num(stage.jvmGCTime),
370
+ inputBytes: num(stage.inputBytes),
371
+ outputBytes: num(stage.outputBytes),
372
+ executorRunTime: num(stage.executorRunTime),
373
+ taskCount: num(stage.taskCount),
374
+ failedTasks: num(stage.failedTasks),
375
+ };
376
+ }
377
+
378
+ function stageList(snap ) {
379
+ return [...snap.stages].map(([id, s]) => ({ id, name: s.name ?? `Stage ${id}`, metrics: perStageMetrics(s) }));
380
+ }
381
+
382
+ // Shared by every buildComparison caller below: captureSnapshot only ever
383
+ // copies this into a fresh Map internally, never mutates the caller's
384
+ // reference, so one shared empty Map is safe in place of a fresh allocation
385
+ // per call.
386
+ const EMPTY_TASK_DATA = new Map ();
387
+
388
+ // Wraps the analyze()-to-compareRuns() snapshot-building sequence shared by
389
+ // the CLI's --baseline path and mcp-tools.ts's compareRuns tool: both need
390
+ // captureSnapshot (with an empty taskDataCache — the interactive drill-down
391
+ // cache, never read by compareRuns/matchStages/metricDeltas/findingsDelta)
392
+ // for each side before diffing them.
393
+ export function buildComparison(
394
+ baseline ,
395
+ candidate ,
396
+ ) {
397
+ return compareRuns(
398
+ { label: baseline.label, snapshot: captureSnapshot(baseline.appModel, baseline.catalog, EMPTY_TASK_DATA) },
399
+ { label: candidate.label, snapshot: captureSnapshot(candidate.appModel, candidate.catalog, EMPTY_TASK_DATA) },
400
+ );
401
+ }
402
+
403
+ export function compareRuns(
404
+ baseline ,
405
+ candidate ,
406
+ ) {
407
+ const baseSnap = baseline.snapshot, candSnap = candidate.snapshot;
408
+ const match = matchStages(baseSnap, candSnap);
409
+ // Name equality is a weak confidence signal, not a hard gate: renaming a job
410
+ // is the normal way to label an A/B experiment, so a mismatch must not block
411
+ // the (matching-free, name-independent) deltas. Surface it as `low` instead.
412
+ const namesDiffer = namesConflict(baseSnap.app, candSnap.app);
413
+ return {
414
+ baselineLabel: baseline.label, candidateLabel: candidate.label,
415
+ confidence: namesDiffer ? 'low' : 'ok',
416
+ reason: namesDiffer ? 'Run names differ, so deltas may compare different work.' : null,
417
+ matchedCoverage: match.coverage,
418
+ metrics: metricDeltas(baseSnap, candSnap),
419
+ findings: findingsDelta(baseSnap, candSnap),
420
+ stageSkew: stageSkewDeltas(baseSnap, candSnap, match),
421
+ baseStages: stageList(baseSnap),
422
+ candStages: stageList(candSnap),
423
+ };
424
+ }
425
+
426
+ // One introduced/resolved findings-delta block: `### <title> (<count>)`
427
+ // heading followed by one bullet per finding.
428
+ function renderFindingsSection(title , findings ) {
429
+ const lines = [`### ${title} (${findings.length})`, ''];
430
+ for (const f of findings) {
431
+ lines.push(`- [${f.impactBand}] ${f.rule}: ${f.baseCount} -> ${f.candCount}`);
432
+ }
433
+ return lines;
434
+ }
435
+
436
+ // Small Markdown renderer for the comparison section, matching
437
+ // evidence-report.ts's renderMarkdown house style (## section heading, ###
438
+ // subheadings, `- key: value` bullets). Shared by the CLI's --baseline
439
+ // markdown output and the MCP server's compare_runs `format: 'md'`.
440
+ export function renderComparisonMarkdown(comparison ) {
441
+ const lines = ['', '## Comparison to baseline', ''];
442
+ if (comparison.confidence === 'low') {
443
+ lines.push(`- confidence: low — ${comparison.reason}`);
444
+ lines.push('');
445
+ }
446
+ lines.push(`- matched stage coverage: ${(comparison.matchedCoverage * 100).toFixed(1)}%`);
447
+ lines.push('');
448
+ lines.push('### Metric deltas');
449
+ lines.push('');
450
+ for (const m of comparison.metrics) {
451
+ lines.push(`- ${m.label}: ${m.baseline ?? 'n/a'} -> ${m.candidate ?? 'n/a'} (${m.direction})`);
452
+ }
453
+ lines.push('');
454
+ lines.push(...renderFindingsSection('Introduced findings', comparison.findings.introduced));
455
+ lines.push('');
456
+ lines.push(...renderFindingsSection('Resolved findings', comparison.findings.resolved));
457
+ return lines.join('\n');
458
+ }
@@ -0,0 +1,73 @@
1
+ // §4 What-if executor-scaling simulator (SparkLens ExecutorWallclockAnalyzer).
2
+ // DESIGN SPIKE: the makespan model is a first-cut approximation and is NOT
3
+ // validated against ground truth (a single event log only ever observes one
4
+ // scale). Every prediction ships with a Model Error confidence indicator.
5
+ //
6
+ // Builds on computeCoreTimeSeries' clamp conceptually, but operates on the
7
+ // worker-posted per-stage aggregates (runAggregates.perStage) so raw task data
8
+ // never reaches the main thread.
9
+ import { computeWallClock } from './wall-clock.js';
10
+ import { computeTotalCores } from './core-count.js';
11
+
12
+
13
+ const TEST_PERCENTAGES = [10, 20, 50, 80, 100, 110, 120, 150, 200, 300, 400, 500];
14
+
15
+ // Idealized makespan: total task-time spread over min(cores, taskCount) cores.
16
+ export function estimatedStageDurationAtCores(totalTaskDurationSum , taskCount , cores ) {
17
+ if (taskCount <= 0 || cores <= 0) return 0;
18
+ return totalTaskDurationSum / Math.min(cores, taskCount);
19
+ }
20
+
21
+ // Sum estimated per-stage durations at N cores. Idealized: ignores scheduling
22
+ // overhead, locality, shuffle-fetch contention (all captured in the real run's
23
+ // actual duration at current scale, hence Model Error).
24
+ function estimatedTotalAtCores(perStage , cores ) {
25
+ let sum = 0;
26
+ for (const stageId of Object.keys(perStage)) {
27
+ const { totalTaskDurationSum, taskCount } = perStage[stageId];
28
+ sum += estimatedStageDurationAtCores(totalTaskDurationSum, taskCount, cores);
29
+ }
30
+ return sum;
31
+ }
32
+
33
+ export function simulateScaling({ app, stages, runAggregates, executorsAdded }
34
+
35
+
36
+
37
+
38
+ )
39
+
40
+
41
+
42
+
43
+ {
44
+ const perStage = runAggregates?.perStage ?? {};
45
+ // `app ?? {}`: computeTotalCores just falls back to the executor-derived core sum when
46
+ // `resources` is absent, and every caller here already tolerates a null `app` (malformed
47
+ // logs with no ApplicationStart); `app!` would crash on `app.resources` in that case.
48
+ // `executorsAdded` cast: computeTotalCores only reads `totalCores`, present on
49
+ // ExecutorAddedEvent (the only kind real callers pass here) but not ExecutorRemovedEvent,
50
+ // so the union as a whole is a structural mismatch against computeTotalCores's
51
+ // `{totalCores?}` shape.
52
+ const baselineCores = computeTotalCores(app ?? {}, executorsAdded );
53
+ const wc = computeWallClock(app, stages );
54
+ const observedActiveMs = wc.stagesActive;
55
+
56
+ // Model Error: predicted-at-baseline vs. observed stages-active wall-clock.
57
+ const predictedAtBaseline = baselineCores > 0 ? estimatedTotalAtCores(perStage, baselineCores) : 0;
58
+ const modelErrorPct = observedActiveMs > 0
59
+ ? Math.round(Math.abs(predictedAtBaseline - observedActiveMs) / observedActiveMs * 100)
60
+ : null;
61
+
62
+ // Scale factor projects the idealized sum onto the observed active wall-clock,
63
+ // so predictions are relative to the real run rather than the raw idealization.
64
+ const scale = predictedAtBaseline > 0 ? observedActiveMs / predictedAtBaseline : 1;
65
+
66
+ const predictions = TEST_PERCENTAGES.map(pct => {
67
+ const cores = Math.max(1, Math.round(baselineCores * pct / 100));
68
+ const estMakespanMs = Math.round(estimatedTotalAtCores(perStage, cores) * scale);
69
+ return { pct, cores, estMakespanMs };
70
+ });
71
+
72
+ return { baselineCores, testPercentages: TEST_PERCENTAGES, predictions, modelErrorPct };
73
+ }
@@ -0,0 +1,79 @@
1
+ // Lightweight in-memory snapshot of a parsed file's view state, so switching
2
+ // between already-parsed files is instant without keeping multiple workers
3
+ // alive. Holds only the summary AppModel + catalog + prefetched task data,
4
+ // never the worker's full taskStore.
5
+
6
+ import { isSupportedEvidenceAvailability } from './evidence-availability.js';
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+ export function captureSnapshot(
32
+ appModel ,
33
+ catalog ,
34
+ taskDataCache ,
35
+ ) {
36
+ return {
37
+ app: appModel.app,
38
+ stages: new Map(appModel.stages),
39
+ executors: {
40
+ added: [...appModel.executors.added],
41
+ removed: [...appModel.executors.removed],
42
+ },
43
+ sql: new Map(appModel.sql),
44
+ jobs: new Map(appModel.jobs),
45
+ runAggregates: appModel.runAggregates,
46
+ evidenceAvailability: appModel.evidenceAvailability,
47
+ catalog: [...catalog],
48
+ taskData: new Map(taskDataCache),
49
+ };
50
+ }
51
+
52
+ // Restores a snapshot by mutating the live `appModel` and `taskDataCache` in
53
+ // place, so references captured elsewhere (e.g. the stage-detail modal closure)
54
+ // keep pointing at the current data. Returns the restored catalog.
55
+ export function applySnapshot(
56
+ appModel ,
57
+ taskDataCache ,
58
+ snapshot ,
59
+ ) {
60
+ appModel.app = snapshot.app;
61
+ appModel.stages = new Map(snapshot.stages);
62
+ appModel.executors = {
63
+ added: [...snapshot.executors.added] ,
64
+ removed: [...snapshot.executors.removed] ,
65
+ };
66
+ appModel.sql = new Map(snapshot.sql);
67
+ appModel.jobs = new Map(snapshot.jobs);
68
+ appModel.runAggregates = snapshot.runAggregates ?? null;
69
+ // Fail-closed on unknown schema versions: a future incompatible ledger is
70
+ // never trusted as V1 data (fixes: schemaVersion must be validated at read).
71
+ appModel.evidenceAvailability = isSupportedEvidenceAvailability(snapshot.evidenceAvailability)
72
+ ? snapshot.evidenceAvailability
73
+ : null;
74
+
75
+ taskDataCache.clear();
76
+ for (const [k, v] of snapshot.taskData) taskDataCache.set(k, v);
77
+
78
+ return [...snapshot.catalog];
79
+ }