Appearance
Run comparison internals
How the A/B comparison engine matches stages and builds a comparison.
Run comparison
packages/core/src/run-comparison.ts is the whole A/B engine. The entry point compareRuns(baseline, candidate) takes two { label, snapshot } run records (each snapshot a SessionSnapshot: app, stages, executors, sql, jobs, runAggregates, evidenceAvailability, catalog, taskData) and returns one plain object the view renders. It runs on already-parsed snapshots, with no worker involved.
stageIdentity(stage, snapshot)is a run-independent key:normalizeStageName(lowercased, digit-runs and long hex ids collapsed to#) joined with the stage's SQL-execution plan identity. That identity is scoped to only the plan nodes this stage's tasks were attributed to (node.stageIds), not the whole tree, so two stages sharing one SQL execution (e.g. a self-join's two Exchange stages) don't collapse onto one identity; it falls back to a bottom-up structural fingerprint of the whole resolvedplanTree(planTreeIdentity,normalizeDetail-normalized: the same normalizercachingOpportunityuses inpackages/core/src/detectors.ts) when a stage has no such attribution. The stage-to-plan mapping itself isplanNodesOfStageinpackages/core/src/stage-plan-nodes.ts, shared withisPythonStage(python-stage.ts), which the CLI'smetricsblock (run-metrics.ts) and thetasksMostlyIdlecheck both read. The CLI keys its per-stage metrics rows by this identity.matchStages(baseSnap, candSnap)indexes each run by that identity and pairs identities that map to exactly one stage on both sides. An identity colliding equally on both sides (same count) is also paired, positionally by sorted stage id: exact when comparing a run against itself (every stage matches itself), a best-effort guess otherwise (two unrelated same-named stages with no SQL/attribution could get cross-paired). Collisions are still recorded incollisionIdentitieseven when resolved this way; a differing count leaves them there unpaired.coveragereports the matched fraction.metricDeltascomputes whole-run aggregate deltas (wall-clock, spill, task skew p95, failed-task rate, GC, I/O bytes, executor count, ...) as plain sums over all stages, deliberately not gated on stage matching, since matching is unreliable on real logs. Each metric carries adirection(improvement/regression/unchanged) and anunavailableReasonwhen a side lacks the field.findingsDeltatallies each run'scatalogby(rule × impact band)and reportsintroducedvsresolvedcategories: a count diff, also matching-free.alignStages(baseSnap, candSnap, { normalizePath })inpackages/core/src/stage-alignment.tsis the aligner: a pure function from two snapshots to the stage pairs, the unmatched stages, the replanned groups, the Delta bookkeeping stages, the runtime coverage and the execution alignment.compareRunscalls it and does not contain it, and every consumer of a comparison reads itsstagePairsinstead of comparing stage keys. It pairs in two levels.- Keys. The exact key is
comparisonIdentity, thestageIdentityrecipe (shared inpackages/core/src/stage-identity.ts) run with the comparison normalizer:normalizeDetail, then the targeted patches inCOMPARISON_PATCHES(staging directories, dates,INlists, file-index counts), with any caller-supplied patterns (compileNormalizePatterns) applied first to the text as Spark printed it. The structural key (structuralKeyinpackages/core/src/stage-structure.ts) folds the attributed nodes into a forest, each node as its normalized name, its sorted attribute names (name#idreferences without the id, so a literal never enters it) and its children's digests; codegen and input-adapter nodes are skipped.stageIdentitystays the exact key the CLI's per-stage metrics rows use, andnormalizeDetailstays untouched, so findings do not move. Blanket numeric or path stripping is not part of the normalizer: it fused distinct stages of one run. - Executions first.
indexRungroups each run's non-bookkeeping stages by SQL execution (stages with no execution form one pseudo-execution) and gives each execution its plan structure, call-site frames and description tokens.alignSequences(packages/core/src/sequence-alignment.ts) runs the order-preserving weighted LCS over the executions in id order, scored byexecutionSimilarity(weights 4, 2 and 1 for structure, frames and description; a part one side lacks drops out) withEXECUTION_MATCH_MIN0.55. It prefers matching where matching is optimal and otherwise skips the execution with the higher id, which makes the result deterministic and mirror-symmetric. Up toFULL_ALIGNMENT_MAX_CELLS(1,000,000) the table is full; above it the table is a band of half-widthBAND_BASE_HALF_WIDTH(100) plus half of the length difference, andexecutionAlignment.boundedsays so. If fewer thanMIN_EXECUTION_AGREEMENT(0.5) of the smaller run's executions pair, no stage pairs (executionAlignment.acceptedis false). - Stages second (
alignWithin). In each aligned execution pair: equal exact key (exact, pairing off in stage-id order as many as both sides have), equal structural key (structural), detail-token Jaccard of at leastALIGNED_MIN_SIMILARITY(0.6, best pair first;aligned), and plan-less stages by position among those of one name (aligned,POSITIONAL_SCORE). Stages outside any execution key on their call-site text too. - Re-plan fallback. When an aligned SQL execution pair holds different numbers of non-bookkeeping stages and stages are left over, they form one
ReplannedGroup(both execution ids, the leftover ids per side,deltastotalled per side). Equal counts leave them unmatched. WithREPLANNED_COUNTS_TOWARD_COVERAGEthe replanned run time counts in the numerator ofruntimeCoverage; set it to false and it counts in the denominator only.
Stages whose scans only read the Delta log (
isDeltaBookkeepingStage, built onisDeltaLogReadinplan-summary.ts) are set aside before pairing. A pair'sdeltassum every attempt of the stage, the sumswithEarlierAttemptsgives the run totals. The result iscomparisonSchemaVersion1.- Keys. The exact key is
The per-stage skew deltas (
stageSkewDeltas) read the aligner's pairs, the same ones the banner's coverage figure counts, and the pinned-stage panel readsbaseStages/candStages. The oldmatchStagespairs still feedmatchedCoverage, which keeps its count-based meaning and decides nothing.
compareRuns sets confidence from runtimeCoverage: insufficient when it is null (neither run recorded executor run time), low when it is under 0.9 (RUNTIME_COVERAGE_THRESHOLD) or the two app names differ (either condition alone is enough; both are weak signals, not a hard gate), ok otherwise. It is computed in compareRuns, not in buildComparison, so the dashboard, which calls compareRuns directly, gets the same result as the CLI and MCP. The view lives in src/view/RunComparison.tsx (the comparison page), CompareLanding.tsx (the two-slot baseline / candidate intake off the landing), and PinnedStageDeltas.tsx (the manual per-stage pinning panel fed by baseStages/candStages).