sparkforensics-server 0.5.0 → 0.5.1

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 (280) hide show
  1. package/package.json +3 -3
  2. package/public/assets/{AutoscalingChurn-9Zo6IMGS.js → AutoscalingChurn-5hDGIXi4.js} +1 -1
  3. package/public/assets/{CacheUtilization-MBDfMsKC.js → CacheUtilization-CT2SCnJJ.js} +1 -1
  4. package/public/assets/{CachingOpportunity-Cfw3wAFa.js → CachingOpportunity-D_O_WkbH.js} +1 -1
  5. package/public/assets/{ColdStart-BtS12b6_.js → ColdStart-B38OiAOp.js} +1 -1
  6. package/public/assets/{ConfigAudit-DNPV6EQg.js → ConfigAudit-BoplryCH.js} +1 -1
  7. package/public/assets/{CoreUsageArea-BObEeenE.js → CoreUsageArea-csgwNxkl.js} +1 -1
  8. package/public/assets/{Dashboard-CC_BXrNy.js → Dashboard-BgKodJl2.js} +2 -2
  9. package/public/assets/{DisclosureContext-Ds0hcZFn.js → DisclosureContext-B0siMVdg.js} +1 -1
  10. package/public/assets/{DuplicatePlanSubtree-vN3zqRsi.js → DuplicatePlanSubtree-BqS2A2j4.js} +1 -1
  11. package/public/assets/{ExecutorUtilization-CZObSLIj.js → ExecutorUtilization-Dp52QGWo.js} +1 -1
  12. package/public/assets/{GcPressure-ByJlLOua.js → GcPressure-CT1yzR_s.js} +1 -1
  13. package/public/assets/{IncompleteRun-MzqV4W8y.js → IncompleteRun-C7Qkjtjn.js} +1 -1
  14. package/public/assets/{JobFailures-LbMvAwAb.js → JobFailures-WHl3ELfh.js} +1 -1
  15. package/public/assets/{MemoryUtilization-DH4jYvJK.js → MemoryUtilization-D8IUDHJI.js} +1 -1
  16. package/public/assets/{OverBroadcast-euk6HKg4.js → OverBroadcast-6oSSAQ_R.js} +1 -1
  17. package/public/assets/{PartitionHint-DThaMMRT.js → PartitionHint-CUvbnnxy.js} +1 -1
  18. package/public/assets/{PartitionSizing-yIN-iZzx.js → PartitionSizing-BwdFL-mw.js} +1 -1
  19. package/public/assets/{PlanExplorer-Dd9WPK4S.js → PlanExplorer-DcxmiQWL.js} +1 -1
  20. package/public/assets/{PlanGraphRoute-NV5mCXRm.js → PlanGraphRoute-pqnHTt4i.js} +1 -1
  21. package/public/assets/{RetryWaste-CXH4D-xH.js → RetryWaste-BTbeHkJc.js} +1 -1
  22. package/public/assets/{ShuffleIO-CW7U68ph.js → ShuffleIO-CRhrYNE0.js} +1 -1
  23. package/public/assets/{Skew-PL79TppX.js → Skew-DSNXRG9V.js} +1 -1
  24. package/public/assets/{SlowHost-GBO73hCi.js → SlowHost-B7Lwu2EU.js} +1 -1
  25. package/public/assets/{SmallFiles-B5cUjEav.js → SmallFiles-C082c2rd.js} +1 -1
  26. package/public/assets/{SpeculationWaste-mxi3jgb0.js → SpeculationWaste-BS2vpadr.js} +1 -1
  27. package/public/assets/{Spill-CH1bNpxL.js → Spill-B4DVKHXx.js} +1 -1
  28. package/public/assets/{StageFailed-YvxC11ii.js → StageFailed-D3ac9Sou.js} +1 -1
  29. package/public/assets/{StageFindingGroup-B1A0nf-e.js → StageFindingGroup-D9naK109.js} +1 -1
  30. package/public/assets/{StageHeader-BMNmUy-s.js → StageHeader-pI1nTwVf.js} +1 -1
  31. package/public/assets/{StageShape-DO3mQ6NS.js → StageShape-eO5le8iG.js} +1 -1
  32. package/public/assets/{StageSlowness-SwRxfAEx.js → StageSlowness-BaNyfNvL.js} +1 -1
  33. package/public/assets/{Straggler-DHLTiBY-.js → Straggler-D5Fnok_y.js} +1 -1
  34. package/public/assets/{TaskFailures-Bj0QxiOc.js → TaskFailures-BWnC-K_G.js} +1 -1
  35. package/public/assets/{TinyTask-COUQQ5bQ.js → TinyTask-8_5lpq_2.js} +1 -1
  36. package/public/assets/{UnderBroadcast-RAUbQ6vw.js → UnderBroadcast-DJ0XSwth.js} +1 -1
  37. package/public/assets/{finding-anchor-Dl8mIHiU.js → finding-anchor-Cys8Z6Yn.js} +1 -1
  38. package/public/assets/{impact-sort-Eihua9dY.js → impact-sort-BTQndbof.js} +1 -1
  39. package/public/assets/index-DCeWge12.js +13 -0
  40. package/public/assets/parser-worker-DmeCnLWU.js +122 -0
  41. package/public/assets/{plan-graph-vendor-B0sEms9Z.js → plan-graph-vendor-BYl-AzN5.js} +1 -1
  42. package/public/assets/{react-vendor-BAKt3vny.js → react-vendor-DEdfrRTJ.js} +1 -1
  43. package/public/assets/{select-DkjFHlXR.js → select-DIYN6D3y.js} +1 -1
  44. package/public/assets/{usePagedRows-Bo-aSztx.js → usePagedRows-BxvdFnPk.js} +1 -1
  45. package/public/assets/{useRecentFiles-Bb4-ukVz.js → useRecentFiles-C3Ov47U7.js} +4 -4
  46. package/public/assets/{useSortMode-lDedD5a3.js → useSortMode-B8SRfAoG.js} +1 -1
  47. package/public/docs/404.html +2 -2
  48. package/public/docs/assets/{app.yjczoibf.js → app.D2GqAkhy.js} +1 -1
  49. package/public/docs/assets/chunks/@localSearchIndexroot.D4OdkQjy.js +1 -0
  50. package/public/docs/assets/chunks/{VPLocalSearchBox.DOBbPfix.js → VPLocalSearchBox.BS4cSmtW.js} +1 -1
  51. package/public/docs/assets/chunks/{theme.DFcAl8Ws.js → theme.BuxVpb0H.js} +2 -2
  52. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.YQcj5EI4.js +1 -0
  53. package/public/docs/assets/{contributor-guide_architecture_board-widgets.md.D0Kkk_r5.lean.js → contributor-guide_architecture_board-widgets.md.YQcj5EI4.lean.js} +1 -1
  54. package/public/docs/assets/contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.js +1 -0
  55. package/public/docs/assets/contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.lean.js +1 -0
  56. package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.js +1 -0
  57. package/public/docs/assets/contributor-guide_architecture_board-widgets_plan-advisor.md.RlzvUmNm.lean.js +1 -0
  58. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.js +1 -0
  59. package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.lean.js +1 -0
  60. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.wj6Et6UD.js +1 -0
  61. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.wj6Et6UD.lean.js +1 -0
  62. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.js +1 -0
  63. package/public/docs/assets/contributor-guide_architecture_detector-contract_plan-attribution.md.yNex4bys.lean.js +1 -0
  64. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.fkPBXsU2.js +1 -0
  65. package/public/docs/assets/contributor-guide_architecture_detector-contract_threshold-tables.md.fkPBXsU2.lean.js +1 -0
  66. package/public/docs/assets/contributor-guide_architecture_drill-down.md.CqZPrdo9.js +1 -0
  67. package/public/docs/assets/contributor-guide_architecture_drill-down.md.CqZPrdo9.lean.js +1 -0
  68. package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.js +1 -0
  69. package/public/docs/assets/contributor-guide_architecture_drill-down_plan-graph.md.DwOozIEs.lean.js +1 -0
  70. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.js +1 -0
  71. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.lean.js +1 -0
  72. package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.js +1 -0
  73. package/public/docs/assets/contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.lean.js +1 -0
  74. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.js +1 -0
  75. package/public/docs/assets/contributor-guide_architecture_impact-estimation_stage-union-rollup.md.DdCGtAGu.lean.js +1 -0
  76. package/public/docs/assets/contributor-guide_architecture_index.md.tymxf1Fh.js +1 -0
  77. package/public/docs/assets/{contributor-guide_architecture_index.md.B9yZieYL.lean.js → contributor-guide_architecture_index.md.tymxf1Fh.lean.js} +1 -1
  78. package/public/docs/assets/contributor-guide_architecture_overview.md.C1sSvm9i.js +1 -0
  79. package/public/docs/assets/contributor-guide_architecture_state-and-history.md.DyCBdjuM.js +1 -0
  80. package/public/docs/assets/{contributor-guide_architecture_state-and-history.md.w4lwybdh.lean.js → contributor-guide_architecture_state-and-history.md.DyCBdjuM.lean.js} +1 -1
  81. package/public/docs/assets/contributor-guide_architecture_state-and-history_history-server-intake.md.C298hrHZ.js +1 -0
  82. package/public/docs/assets/contributor-guide_architecture_state-and-history_history-server-intake.md.C298hrHZ.lean.js +1 -0
  83. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.uuAKpY0Y.js +1 -0
  84. package/public/docs/assets/contributor-guide_architecture_state-and-history_run-comparison-internals.md.uuAKpY0Y.lean.js +1 -0
  85. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BOZ1azXG.js +1 -0
  86. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.BOZ1azXG.lean.js +1 -0
  87. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.js +1 -0
  88. package/public/docs/assets/contributor-guide_architecture_widget-rendering_findings-tab.md.BQXycK6G.lean.js +1 -0
  89. package/public/docs/assets/contributor-guide_architecture_widget-rendering_investigation-routing.md.BGdiyElu.js +1 -0
  90. package/public/docs/assets/contributor-guide_architecture_widget-rendering_investigation-routing.md.BGdiyElu.lean.js +1 -0
  91. package/public/docs/assets/contributor-guide_architecture_widget-rendering_list-sort-mode.md.BuHKgH0C.js +1 -0
  92. package/public/docs/assets/contributor-guide_architecture_widget-rendering_list-sort-mode.md.BuHKgH0C.lean.js +1 -0
  93. package/public/docs/assets/contributor-guide_architecture_widget-rendering_reference-section.md.DWqC80bN.js +1 -0
  94. package/public/docs/assets/contributor-guide_architecture_widget-rendering_reference-section.md.DWqC80bN.lean.js +1 -0
  95. package/public/docs/assets/contributor-guide_architecture_widget-rendering_row-expand-contract.md.ZT89u9f1.js +1 -0
  96. package/public/docs/assets/contributor-guide_architecture_widget-rendering_row-expand-contract.md.ZT89u9f1.lean.js +1 -0
  97. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BAnhi2-d.js +1 -0
  98. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.BAnhi2-d.lean.js +1 -0
  99. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.Dk08VS7n.js +1 -0
  100. package/public/docs/assets/contributor-guide_architecture_worker-protocol_cli-and-mcp.md.Dk08VS7n.lean.js +1 -0
  101. package/public/docs/assets/contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DSVFPpWb.js +1 -0
  102. package/public/docs/assets/contributor-guide_architecture_worker-protocol_core-and-event-validation.md.DSVFPpWb.lean.js +1 -0
  103. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-availability.md.CjimIDBY.js +6 -0
  104. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-availability.md.CjimIDBY.lean.js +1 -0
  105. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.js +1 -0
  106. package/public/docs/assets/contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.lean.js +1 -0
  107. package/public/docs/assets/user-guide_alternative-log-retrieval.md.CJpYvHGo.js +4 -0
  108. package/public/docs/assets/user-guide_alternative-log-retrieval.md.CJpYvHGo.lean.js +1 -0
  109. package/public/docs/assets/user-guide_alternative-log-retrieval_managed-platforms.md.DvDwHNtC.js +4 -0
  110. package/public/docs/assets/user-guide_alternative-log-retrieval_managed-platforms.md.DvDwHNtC.lean.js +1 -0
  111. package/public/docs/assets/user-guide_alternative-log-retrieval_ssh-bastion.md.Gszzlplv.js +1 -0
  112. package/public/docs/assets/user-guide_alternative-log-retrieval_ssh-bastion.md.Gszzlplv.lean.js +1 -0
  113. package/public/docs/assets/user-guide_getting-started.md.DYHHHkQR.js +1 -0
  114. package/public/docs/assets/user-guide_getting-started.md.DYHHHkQR.lean.js +1 -0
  115. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.DpnfKz3x.js +22 -0
  116. package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.DpnfKz3x.lean.js +1 -0
  117. package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.js +1 -0
  118. package/public/docs/assets/user-guide_getting-started_reading-the-dashboard.md.Obf3W3__.lean.js +1 -0
  119. package/public/docs/assets/user-guide_getting-started_regression-metric-keys.md.B_gzr2Mw.js +1 -0
  120. package/public/docs/assets/user-guide_getting-started_regression-metric-keys.md.B_gzr2Mw.lean.js +1 -0
  121. package/public/docs/assets/user-guide_getting-started_tuning-thresholds.md.B_MyjyZm.js +4 -0
  122. package/public/docs/assets/user-guide_getting-started_tuning-thresholds.md.B_MyjyZm.lean.js +1 -0
  123. package/public/docs/assets/user-guide_mcp-tools.md.C65SjWUL.js +16 -0
  124. package/public/docs/assets/user-guide_mcp-tools.md.C65SjWUL.lean.js +1 -0
  125. package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.CPowlb2q.js +55 -0
  126. package/public/docs/assets/user-guide_mcp-tools_compare-runs.md.CPowlb2q.lean.js +1 -0
  127. package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.js +23 -0
  128. package/public/docs/assets/user-guide_mcp-tools_diagnose-run.md.BP1vnHA5.lean.js +1 -0
  129. package/public/docs/assets/user-guide_mcp-tools_evaluate-budgets.md.Dq5Azk8G.js +26 -0
  130. package/public/docs/assets/user-guide_mcp-tools_evaluate-budgets.md.Dq5Azk8G.lean.js +1 -0
  131. package/public/docs/assets/user-guide_mcp-tools_get-finding-documentation.md.BtTTHNV6.js +14 -0
  132. package/public/docs/assets/user-guide_mcp-tools_get-finding-documentation.md.BtTTHNV6.lean.js +1 -0
  133. package/public/docs/assets/user-guide_mcp-tools_get-finding-evidence.md.rKyu4wts.js +20 -0
  134. package/public/docs/assets/user-guide_mcp-tools_get-finding-evidence.md.rKyu4wts.lean.js +1 -0
  135. package/public/docs/assets/user-guide_mcp-tools_get-reference-doc.md.Yac4tH3u.js +5 -0
  136. package/public/docs/assets/user-guide_mcp-tools_get-reference-doc.md.Yac4tH3u.lean.js +1 -0
  137. package/public/docs/assets/user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.js +15 -0
  138. package/public/docs/assets/user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.lean.js +1 -0
  139. package/public/docs/assets/user-guide_mcp-tools_list-runs.md.DpQPeCM3.js +12 -0
  140. package/public/docs/assets/user-guide_mcp-tools_list-runs.md.DpQPeCM3.lean.js +1 -0
  141. package/public/docs/assets/{user-guide_run-comparison.md.qin0a4IX.js → user-guide_run-comparison.md.B6aNtDYF.js} +1 -1
  142. package/public/docs/assets/user-guide_run-comparison.md.B6aNtDYF.lean.js +1 -0
  143. package/public/docs/assets/user-guide_run-comparison_how-stages-are-matched.md.XBzQ1oEU.js +1 -0
  144. package/public/docs/assets/user-guide_run-comparison_how-stages-are-matched.md.XBzQ1oEU.lean.js +1 -0
  145. package/public/docs/assets/user-guide_understanding-findings.md.Dqt2H_C1.js +1 -0
  146. package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +30 -0
  147. package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +30 -0
  148. package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +30 -0
  149. package/public/docs/contributor-guide/architecture/board-widgets.html +5 -5
  150. package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +30 -0
  151. package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +30 -0
  152. package/public/docs/contributor-guide/architecture/detector-contract.html +5 -5
  153. package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +30 -0
  154. package/public/docs/contributor-guide/architecture/drill-down.html +5 -5
  155. package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +30 -0
  156. package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +30 -0
  157. package/public/docs/contributor-guide/architecture/impact-estimation.html +5 -5
  158. package/public/docs/contributor-guide/architecture/index.html +5 -5
  159. package/public/docs/contributor-guide/architecture/overview.html +5 -5
  160. package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +30 -0
  161. package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +30 -0
  162. package/public/docs/contributor-guide/architecture/state-and-history.html +5 -5
  163. package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +30 -0
  164. package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +30 -0
  165. package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +30 -0
  166. package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +30 -0
  167. package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +30 -0
  168. package/public/docs/contributor-guide/architecture/widget-rendering.html +5 -5
  169. package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +30 -0
  170. package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +30 -0
  171. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +35 -0
  172. package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +30 -0
  173. package/public/docs/contributor-guide/architecture/worker-protocol.html +5 -10
  174. package/public/docs/contributor-guide/contributing.html +4 -4
  175. package/public/docs/contributor-guide/development-setup.html +4 -4
  176. package/public/docs/contributor-guide/testing.html +4 -4
  177. package/public/docs/hashmap.json +1 -1
  178. package/public/docs/index.html +3 -3
  179. package/public/docs/tuning-reference/anti-patterns.html +3 -3
  180. package/public/docs/tuning-reference/aqe.html +3 -3
  181. package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +3 -3
  182. package/public/docs/tuning-reference/bottleneck-cold-start.html +3 -3
  183. package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +3 -3
  184. package/public/docs/tuning-reference/bottleneck-failures.html +3 -3
  185. package/public/docs/tuning-reference/bottleneck-gc.html +3 -3
  186. package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +3 -3
  187. package/public/docs/tuning-reference/bottleneck-memory-utilization.html +3 -3
  188. package/public/docs/tuning-reference/bottleneck-retry-waste.html +3 -3
  189. package/public/docs/tuning-reference/bottleneck-shuffle.html +3 -3
  190. package/public/docs/tuning-reference/bottleneck-skew.html +3 -3
  191. package/public/docs/tuning-reference/bottleneck-slow-host.html +3 -3
  192. package/public/docs/tuning-reference/bottleneck-small-files.html +3 -3
  193. package/public/docs/tuning-reference/bottleneck-spill.html +3 -3
  194. package/public/docs/tuning-reference/bottleneck-straggler.html +3 -3
  195. package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +3 -3
  196. package/public/docs/tuning-reference/bottleneck-utilization.html +3 -3
  197. package/public/docs/tuning-reference/caching.html +3 -3
  198. package/public/docs/tuning-reference/cluster-config.html +3 -3
  199. package/public/docs/tuning-reference/config.html +3 -3
  200. package/public/docs/tuning-reference/data-formats.html +3 -3
  201. package/public/docs/tuning-reference/index.html +3 -3
  202. package/public/docs/tuning-reference/intro.html +3 -3
  203. package/public/docs/tuning-reference/joins.html +3 -3
  204. package/public/docs/tuning-reference/memory-model.html +3 -3
  205. package/public/docs/tuning-reference/metrics.html +3 -3
  206. package/public/docs/tuning-reference/partitioning.html +3 -3
  207. package/public/docs/tuning-reference/pyspark.html +3 -3
  208. package/public/docs/tuning-reference/shuffle.html +3 -3
  209. package/public/docs/tuning-reference/spark-architecture.html +3 -3
  210. package/public/docs/tuning-reference/table-formats.html +3 -3
  211. package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +33 -0
  212. package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +30 -0
  213. package/public/docs/user-guide/alternative-log-retrieval.html +6 -9
  214. package/public/docs/user-guide/getting-started/ci-and-automation.html +51 -0
  215. package/public/docs/user-guide/getting-started/reading-the-dashboard.html +30 -0
  216. package/public/docs/user-guide/getting-started/regression-metric-keys.html +30 -0
  217. package/public/docs/user-guide/getting-started/tuning-thresholds.html +33 -0
  218. package/public/docs/user-guide/getting-started.html +5 -28
  219. package/public/docs/user-guide/mcp-tools/compare-runs.html +84 -0
  220. package/public/docs/user-guide/mcp-tools/diagnose-run.html +52 -0
  221. package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +55 -0
  222. package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +43 -0
  223. package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +49 -0
  224. package/public/docs/user-guide/mcp-tools/get-reference-doc.html +34 -0
  225. package/public/docs/user-guide/mcp-tools/get-run-summary.html +44 -0
  226. package/public/docs/user-guide/mcp-tools/list-runs.html +41 -0
  227. package/public/docs/user-guide/mcp-tools.html +8 -140
  228. package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +30 -0
  229. package/public/docs/user-guide/run-comparison.html +5 -5
  230. package/public/docs/user-guide/understanding-findings.html +5 -5
  231. package/public/export-template.html +44 -44
  232. package/public/index.html +4 -4
  233. package/vendor-core/analyzer.js +1 -1
  234. package/vendor-core/api-merge-writes.js +80 -0
  235. package/vendor-core/comparison-output.js +49 -0
  236. package/vendor-core/core-source-hash.txt +1 -1
  237. package/vendor-core/delta-commands.js +13 -0
  238. package/vendor-core/delta-log-paths.js +42 -0
  239. package/vendor-core/delta-targets.js +127 -0
  240. package/vendor-core/event-handlers.js +95 -9
  241. package/vendor-core/event-schemas.js +3 -1
  242. package/vendor-core/evidence-report.js +13 -4
  243. package/vendor-core/mcp-server-factory.js +13 -6
  244. package/vendor-core/mcp-tools.js +44 -35
  245. package/vendor-core/plan-graph-model.js +1 -1
  246. package/vendor-core/plan-summary.js +13 -10
  247. package/vendor-core/run-comparison.js +79 -120
  248. package/vendor-core/run-output.js +26 -0
  249. package/vendor-core/stage-alignment.js +262 -0
  250. package/vendor-core/stage-identity.js +112 -0
  251. package/vendor-core/types.js +5 -0
  252. package/vendor-core/write-target-args.js +48 -0
  253. package/vendor-core/write-targets.js +35 -52
  254. package/public/assets/index-DaC8A2FS.js +0 -13
  255. package/public/assets/parser-worker-CfDvPkwF.js +0 -122
  256. package/public/docs/assets/chunks/@localSearchIndexroot.r9imX8Aq.js +0 -1
  257. package/public/docs/assets/contributor-guide_architecture_board-widgets.md.D0Kkk_r5.js +0 -1
  258. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.caA46z-u.js +0 -1
  259. package/public/docs/assets/contributor-guide_architecture_detector-contract.md.caA46z-u.lean.js +0 -1
  260. package/public/docs/assets/contributor-guide_architecture_drill-down.md.CdtlKHl-.js +0 -1
  261. package/public/docs/assets/contributor-guide_architecture_drill-down.md.CdtlKHl-.lean.js +0 -1
  262. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.BNp14LtL.js +0 -1
  263. package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.BNp14LtL.lean.js +0 -1
  264. package/public/docs/assets/contributor-guide_architecture_index.md.B9yZieYL.js +0 -1
  265. package/public/docs/assets/contributor-guide_architecture_overview.md.DBGo-bwn.js +0 -1
  266. package/public/docs/assets/contributor-guide_architecture_state-and-history.md.w4lwybdh.js +0 -1
  267. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.CcvqxnZI.js +0 -1
  268. package/public/docs/assets/contributor-guide_architecture_widget-rendering.md.CcvqxnZI.lean.js +0 -1
  269. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.CKeFttKj.js +0 -6
  270. package/public/docs/assets/contributor-guide_architecture_worker-protocol.md.CKeFttKj.lean.js +0 -1
  271. package/public/docs/assets/user-guide_alternative-log-retrieval.md.z-8npxNk.js +0 -7
  272. package/public/docs/assets/user-guide_alternative-log-retrieval.md.z-8npxNk.lean.js +0 -1
  273. package/public/docs/assets/user-guide_getting-started.md.BJW5oVhe.js +0 -24
  274. package/public/docs/assets/user-guide_getting-started.md.BJW5oVhe.lean.js +0 -1
  275. package/public/docs/assets/user-guide_mcp-tools.md.DcLiOa9t.js +0 -148
  276. package/public/docs/assets/user-guide_mcp-tools.md.DcLiOa9t.lean.js +0 -1
  277. package/public/docs/assets/user-guide_run-comparison.md.qin0a4IX.lean.js +0 -1
  278. package/public/docs/assets/user-guide_understanding-findings.md.DzReyCXb.js +0 -1
  279. /package/public/docs/assets/{contributor-guide_architecture_overview.md.DBGo-bwn.lean.js → contributor-guide_architecture_overview.md.C1sSvm9i.lean.js} +0 -0
  280. /package/public/docs/assets/{user-guide_understanding-findings.md.DzReyCXb.lean.js → user-guide_understanding-findings.md.Dqt2H_C1.lean.js} +0 -0
@@ -1 +0,0 @@
1
- import{_ as o,o as t,c,a5 as d}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Board widgets","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/board-widgets.md","filePath":"contributor-guide/architecture/board-widgets.md"}'),a={name:"contributor-guide/architecture/board-widgets.md"};function i(n,e,r,s,l,h){return t(),c("div",null,[...e[0]||(e[0]=[d('<h1 id="board-widgets" tabindex="-1">Board widgets <a class="header-anchor" href="#board-widgets" aria-label="Permalink to &quot;Board widgets&quot;">​</a></h1><h2 id="board-widgets-app-and-plan-widgets" tabindex="-1">App- and plan-level widgets <a class="header-anchor" href="#board-widgets-app-and-plan-widgets" aria-label="Permalink to &quot;App- and plan-level widgets {#board-widgets-app-and-plan-widgets}&quot;">​</a></h2><p>(Components below live in <code>src/view/widgets/</code>, one file per widget name, e.g. <code>JobFailures.tsx</code>, <code>MemoryUtilization.tsx</code>.)</p><p>Beyond the stage-level cards, nine app- and plan-level cards render in the Findings tab, all <code>region: &#39;action&#39;</code> in <code>detector-registry.tsx</code>: Incomplete Run, Job Failures, Caching Opportunities, Autoscaling Churn, Config Audit, and the four Plan Advisor cards (Redundant Plan Subtree, Excessive Small Files, Missed Broadcast Join, Oversized Broadcast Join). <code>REGISTRY</code> maps every finding type to its own component: 24 <code>action</code> and 4 <code>reference</code> entries.</p><ul><li><p><strong>Incomplete Run</strong> (tag <code>INCMP</code>, <code>IncompleteRun.tsx</code>): app-level &quot;the capture never finished&quot; caveat (DETECTORS entry <code>incompleteRun</code>). Fires whenever <code>app.startTime</code> was observed but <code>app.endTime</code> was not, i.e. no <code>SparkListenerApplicationEnd</code> in the log: an in-flight job, a rotated-away log, or a capture cut short. Order 5, the lowest of any detector, so within the Warning band (its fixed impact band) its card comes first. The Findings tab&#39;s recommendation rollup (rendered by <code>FixTheseFirst.tsx</code>) excludes <code>incompleteRun</code> from that list outright (see <a href="./widget-rendering.html#widget-rendering-order">Widget rendering order</a>): it&#39;s a pipeline-completeness caveat, not an addressable fix, so it never competes with other findings for a ranked slot there. Self-gates to <code>null</code> (no card in the DOM) once the run completed normally. Unrelated to <code>evidence-availability.ts</code>&#39;s own <code>trustworthy</code> gate (see <a href="./worker-protocol.html#evidence-availability-contract">Evidence-availability contract</a>): that ledger only downgrades <em>absence</em> conclusions for individual evidence categories, never becomes a <code>DETECTORS</code> finding itself, and this card does not read it. No <code>docAnchor</code> is set: this is a tool-specific signal with no upstream <code>spark-tuning-reference</code> section.</p></li><li><p><strong>Job Failures</strong> (tag <code>JOBS</code>): app-level job-failure-rate rollup (DETECTORS entry <code>jobFailureRate</code> in <code>packages/core/src/detectors.ts</code>), computed from the run&#39;s <code>jobs</code> map (<code>ctx.jobs</code>, <code>SparkListenerJobEnd</code> results): ≥10% info, ≥30% warning, ≥50% critical. Complements the per-stage, task-level Failed Tasks card (tag <code>FAIL</code>).</p></li><li><p><strong>Caching Opportunities</strong> (tag <code>CACHE</code>, <code>CachingOpportunity.tsx</code>): app-level SQL relation-reuse detector (DETECTORS entry <code>cachingOpportunity</code>), computed from <code>ctx.sql</code>. It walks each execution&#39;s <code>planTree</code> and keys every scan by its stable pre-AQE identity via <code>scanRelationId</code> (<code>plan-summary.ts</code>): <code>&lt;parquet|orc|csv|json&gt;:&lt;db.table&gt;</code>, <code>delta:&lt;db.table&gt;</code>, <code>jdbc:&lt;schema.table&gt;</code>. That dedupes relations within one execution (self-joins count once) and flags any relation scanned by <code>&gt;= minExecutions</code> (2) distinct executions. Relation identity comes from the catalog-qualified scan name (nodeName, e.g. <code>Scan parquet spark_catalog.db.t</code>), not the <code>Location:</code> path, since Spark truncates that path at ~100 chars and points it at <code>_delta_log</code> for Delta tables; internal Delta-log metadata scans are dropped. Read bytes come from the scan&#39;s <code>size of files read</code> metric (<code>0</code>/unknown for JDBC). Findings carry <code>executionReuse</code> (<code>value</code> = distinct-execution count), <code>relation</code>, <code>format</code>, <code>executionIds</code>, <code>totalReadBytes</code>, and a size-aware <code>recommendation</code> (<code>confidence</code> scaled <code>low</code>/<code>medium</code>/<code>high</code> via <code>cachingReuseConfidence</code> off reuse-execution count, plus <code>validationRequired</code>). Renders nothing when clean (no card in the DOM). One row per relation: name + format badge, reuse count, <code>Data read</code> (<code>formatBytes</code>, em-dash when unknown), sorted by <code>totalReadBytes</code> then reuse count descending; the card states the fix once (<code>fixFor</code>), not per row. Rows are paged <code>VISIBLE_LIMIT</code> (6) at a time (<code>usePagedRows</code> + <code>RowPagination</code>), and a route to a row jumps to its page. Pure-RDD-API apps (no SQL executions) produce no finding: a deliberate trade-off, since an RDD-lineage heuristic surfaces only internal query-engine RDDs on DataFrame/SQL workloads.</p><p>It also detects composite reuse. When the same join/union subtree (not just a leaf scan) recurs across <code>&gt;= minExecutions</code> distinct SQL executions, the detector emits one <code>variant:&#39;composite&#39;</code> finding recommending caching the derived join/union result instead of two independent leaf-relation rows, and suppresses the leaf findings for relations fully covered by it. A relation reused beyond the composite&#39;s executions keeps a residual leaf finding for just the uncovered executions. Composite identity is structural: an anchor plan-shape fingerprint built by <code>findCompositeCandidates</code> (<code>packages/core/src/detectors.ts</code>; the same shape <code>computePlanShapes</code> produces with <code>opts.includeDetail</code>, computed inline in one bottom-up pass) folds the join/union node&#39;s own normalized <code>detail</code> (join type, columns, literals, with expr ids, <code>plan_id=</code>, codegen-stage numbers, and AQE&#39;s BuildLeft/BuildRight stripped, and commutative equality operands canonicalized) plus its children&#39;s detail-free shapes. Descendant nodes never contribute detail text, only structural shape, so a shared join reused across differently-filtered pre-join scans still matches (a deliberate trade-off) while a different join <em>condition</em> on the same tables does not collide. Nested composites (an inner join reused both standalone and inside an outer join) dedupe one level at a time: an inner composite fully covered by a qualifying outer composite&#39;s execution set is suppressed entirely, and a superset recomputes a residual finding over just its extra executions. Reuse via pure projection/aggregation with no join/union underneath stays leaf-level, not modeled as composite. <code>variant:&#39;composite&#39;</code> findings carry <code>operator</code> (<code>&#39;join&#39;</code>|<code>&#39;union&#39;</code>), <code>relations</code> (leaf relations under the composite, for display), and <code>format:&#39;derived&#39;</code> (a sentinel: composites have no scan storage format). They render a <code>JOIN</code>/<code>UNION</code> operator badge (styled distinct from the format badges) plus a confidence marker (<code>confidence</code> scaled via <code>cachingReuseConfidence</code>, <code>validationRequired</code>) in <code>CachingOpportunity.tsx</code>.</p></li><li><p><strong>Autoscaling Churn</strong> (tag <code>CHRN</code>, <code>AutoscalingChurn.tsx</code>): app-level short-lived-executor detector (DETECTORS entry <code>autoscalingChurn</code> in <code>packages/core/src/detectors.ts</code>; reuses the same <code>executorsAdded</code>/<code>executorsRemoved</code> matching logic as <code>utilization</code>). For each added executor, finds its matching removal event (falling back to <code>app.endTime</code> for an executor still alive when the log ends) and flags it short-lived if its lifetime is under 2 minutes (<code>thresholds.shortLivedMs</code>). Warns above 30% short-lived, escalates to critical above 60% (<code>confidence</code> scales <code>low</code>/<code>medium</code>/<code>high</code> via <code>autoscalingChurnConfidence</code>, off how far the short-lived share sits past <code>warningPct</code>/<code>criticalPct</code>: these thresholds are unvalidated estimates, not checked against real autoscaling-heavy logs). Returns no finding below 5 total executors (noise floor) or when <code>app.endTime</code> is missing (truncated/still-running log). The widget shows a finding-driven verdict banner (impact dot + <code>CHRN</code> tag + recommendation) above its add/remove chart, whose scale-down bars are muted. Its <code>docAnchor</code> is <code>#bottleneck-autoscaling-churn</code>, a section of the cluster-config chapter (<code>docs-content/chapters/11-cluster-config.md</code>), not a bottleneck page of its own: <code>pageForAnchor</code> maps it there, so the <code>CHRN</code> pill opens <code>cluster-config.html#bottleneck-autoscaling-churn</code>.</p></li><li><p><strong>Config Audit</strong> (tag <code>CFG</code>): static Spark-config sanity findings derived from <code>app.config</code>/<code>app.resources</code> (parsed from <code>SparkListenerEnvironmentUpdate</code>): dynamic-allocation vs. shuffle-service mismatch, inverted/missing autoscaling bounds, non-Kryo serializer, low executor <code>memoryOverhead</code>. Computed outside the runtime bottleneck catalog: its findings live in the separate <code>configFindings</code> stream, not <code>catalog</code>. But <code>FixTheseFirst</code>/<code>Alerts</code> both merge <code>catalog</code> and <code>configFindings</code> (neither reads <code>region</code>), so a Config Audit finding is eligible for a row in the Findings tab&#39;s recommendation rollup and gets its own card in the active grid, alongside genuine bottleneck-catalog findings.</p></li><li><p><strong>Plan Advisor</strong> (tag <code>PLAN</code>): SQL-plan-level findings computed from <code>appModel.sql</code>&#39;s resolved <code>planTree</code> (DETECTORS entries <code>duplicatePlanSubtree</code>, <code>smallFiles</code>, <code>broadcastSizing</code> in <code>packages/core/src/detectors.ts</code>): repeated plan subtrees (≥3 nodes, ≥2 occurrences; dropped when their linked stages take under 0.5% of the run; banded from the recovered wall-clock like other findings, with a <code>warning</code>/<code>info</code> fallback; an <code>Exchange</code> root only changes the recommendation to a possible missed exchange reuse), small-files read/write (&gt;100 files averaging ❤️ MiB), and broadcast-join sizing in both directions (missed- broadcast info finding, over-broadcast warning at &gt;1 GB). Each of the four emitted types renders as its own card (<code>DuplicatePlanSubtree.tsx</code>, <code>SmallFiles.tsx</code>, <code>UnderBroadcast.tsx</code>, <code>OverBroadcast.tsx</code>): all four share the <code>PLAN</code> tag and the same <code>--plan-aggregate</code> badge tint (<code>src/view/plan-finding-shared.ts</code>). Their <code>docAnchor</code>s (<code>#bottleneck-duplicate-plan-subtree</code>, <code>#bottleneck-small-files</code>, <code>#bottleneck-broadcast-sizing</code>) each resolve to their own page in the vendored tuning reference.</p></li></ul><p><code>detector-registry.tsx</code>&#39;s <code>REGISTRY</code> carries <code>region: &#39;reference&#39;</code> on four entries, each its own component: <code>memoryUtilization</code> (<code>MemoryUtilization</code>), <code>utilization</code> (<code>ExecutorUtilization</code>), <code>cacheUtilization</code> (<code>CacheUtilization</code>), and <code>coreLocality</code> (<code>CoreUsageArea</code>). <code>broadcastSizing</code> has no <code>REGISTRY</code> entry: it never backs a real <code>Finding</code> (the <code>broadcastSizing</code> <code>DETECTORS</code> entry only emits <code>underBroadcast</code>/<code>overBroadcast</code>, both <code>region: &#39;action&#39;</code>, each its own Plan Advisor card), so there is no key for it to occupy or clean-check line for it to render. Autoscaling Churn, the other executor-provisioning-lifecycle detector alongside <code>utilization</code>/<code>memoryUtilization</code>, is <code>action</code>, not <code>reference</code> (see &quot;App- and plan-level widgets&quot; above).</p><p><code>region</code> decides one thing (see <a href="./widget-rendering.html#widget-rendering-order">Widget rendering order</a>): whether a widget always mounts. <code>isAlwaysMountedType()</code> flags exactly one of the four <code>reference</code>-region types: Core Usage by Locality. That one mounts unconditionally from <code>appModel</code> at the head of the Full app report tab&#39;s reference grid, regardless of finding state. Memory Utilization, Executor Utilization, and Cache Storage are <code>reference</code> too, but all three are excluded by product decision, not a component-sharing constraint (<code>ALWAYS_MOUNTED_EXCEPTIONS</code> in <code>src/view/detector-registry.tsx</code> carries <code>cacheUtilization</code>, <code>memoryUtilization</code>, and <code>utilization</code>): a clean run on any of them isn&#39;t evidence worth surfacing unconditionally, so each collapses to an ordinary <code>CleanCheckRow</code> like any other action-region type on a clean run. Every other <code>REGISTRY</code> widget renders unconditionally as either an active card or a clean-check line, and <code>region</code>, then detector order, breaks ties between cards of the same impact band (<code>computeActiveWidgets</code> ranks by worst impact band first; <code>ImpactBoard</code> groups cards Critical, Warning, Info). The Full app report tab reads no other <code>REGISTRY</code> entry. So Core Usage by Locality, the one widget tagged <code>region: &#39;reference&#39;</code> and exempt from <code>ALWAYS_MOUNTED_EXCEPTIONS</code>, renders in the Full app report beside the other run-wide reference views, and a route to a <code>coreLocality</code> finding switches to that tab (Memory Utilization, Executor Utilization, and Cache Storage all render through the ordinary active/clean paths instead):</p><ul><li><strong>Memory Utilization</strong> (tag <code>MEM</code>): app-level card combining three sub-findings from the <code>memoryUtilization</code> DETECTORS entry (<code>packages/core/src/detectors.ts</code>): idle-cores rate (busy-core-time from the worker&#39;s run-aggregates sweep vs. peak-cores × wall-clock), per-executor memory bands (peak heap vs. allocated, gated on <code>spark.eventLog.logStageExecutorMetrics</code>: a distinct <code>dataUnavailable</code> finding renders when that config was off), and an unverified memory-waste model (<code>confidence</code> scales <code>low</code>/<code>medium</code>/<code>high</code> via <code>memoryWasteConfidence</code>, off how far the wasted/used ratio sits past the 1.5× buffer). There is no driver-memory band: the worker only extracts <em>allocated</em> <code>spark.driver.memory</code>, never a driver actual-usage metric, so there is nothing to band against. The separate <code>utilization</code> DETECTORS entry (an <code>avgUtilization</code> info finding: active-executor-time fraction below 60%) has its own card, <strong>Executor Utilization</strong> (tag <code>UTIL</code>, <code>ExecutorUtilization.tsx</code>): a <code>reference</code>-region widget in its own right that renders only with an active finding.</li><li><strong>Cache Storage</strong> (tag <code>CSTOR</code>): app-level card driven by the <code>cacheUtilization</code> DETECTORS entry (<code>packages/core/src/detectors.ts</code>), evaluating two per-RDD proxies over <code>ctx.app.rddInfo</code> since Spark event logs carry no runtime block-access/read-count data. <code>rddInfo</code>&#39;s cache figures come from <code>SparkListenerBlockUpdated</code> (<code>recordBlockUpdate</code> in <code>event-handlers.ts</code>, only written with <code>spark.eventLog.logBlockUpdates.enabled=true</code>): each RDD&#39;s peak count of resident partitions, with the memory/disk bytes at the latest moment that peak held, so an <code>unpersist()</code> before the log ends doesn&#39;t erase it. A block&#39;s bytes count only where its storage level says it lives (as in Spark&#39;s <code>AppStatusListener</code>): a drop from memory to disk still reports the dropped bytes as <code>Memory Size</code>. A removed executor&#39;s blocks are dropped with it (Spark logs no update for them), and once an RDD has block updates a later stage&#39;s RDD Info can&#39;t reset its storage level to <code>NONE</code> after an <code>unpersist()</code>. The corpus <code>cache-memory-only</code> and <code>cache-memory-and-disk</code> logs exercise both rules. Without block updates they fall back to <code>SparkListenerStageSubmitted</code>&#39;s RDD Info (<code>storageSource</code> records which), which is always 0 since Spark 2.3; Spark 1.x fills it only on <code>StageCompleted</code>, which isn&#39;t read. The two proxies are partial caching (<code>numCachedPartitions / numPartitions &lt; 0.90</code>, <code>&lt; 0.50</code> for the warning tier) and disk spillover for <code>MEMORY_AND_DISK*</code> RDDs (<code>diskSize / (memorySize + diskSize) &gt; 0.15</code>, <code>&gt; 0.40</code> for the warning tier; <code>DISK_ONLY</code> RDDs are never flagged). <code>confidence</code> scales <code>low</code>/<code>medium</code>/<code>high</code> via <code>cacheSampleConfidence(rdd.numPartitions)</code>, because the ratio is a storage snapshot, not a runtime read-count, and more partitions average that snapshot noise into a more stable ratio. When persisted RDDs have no storage evidence at all (no block updates, block-update logging not enabled in the app config, a recorded Spark version of 2.3 or later, and every RDD Info figure 0), the detector emits one <code>storageUnobserved</code> caveat (<code>dataUnavailable: true</code>, <code>info</code>) naming <code>spark.eventLog.logBlockUpdates.enabled</code>. Unlike <code>memoryUtilization</code>&#39;s caveat it counts for <code>isRealFinding</code>, so the card still mounts (with the caveat and no table) and Cache Storage never lands in Clean checks for a run that couldn&#39;t be checked; <code>isEligible</code> keeps it out of Fix these first. The RDD table renders unconditionally; flagged rows get an inline <code>CSTOR</code> tag next to the RDD name, and every flagged RDD&#39;s recommendation renders below the table, worst-first.</li><li><strong>Core Usage by Locality</strong> (tag <code>LOCAL</code>, <code>CoreUsageArea.tsx</code>): app-level non-local-task-ratio threshold (DETECTORS entry <code>coreLocality</code>; the other half of a wasted-cores-ratio check, the idle-core half already covered by Memory Utilization&#39;s <code>idleCores</code> variant above). Sums <code>RACK_LOCAL</code> + <code>ANY</code> task counts against total task count across every stage&#39;s <code>stage.localityStats</code> (pure reducer <code>computeCoreLocalityRatio</code>, <code>packages/core/src/core-locality-ratio.ts</code>). <code>NO_PREF</code> stays in the denominator only, since it&#39;s what shuffle-read stages legitimately report with no locality problem. Below 50 total tasks or below a 15% non-local ratio: no finding; 15%-35%: warning; &gt;= 35%: critical; these thresholds are unvalidated estimates (<code>confidence</code> scales <code>low</code>/<code>medium</code>/<code>high</code> via <code>coreLocalityConfidence</code>, off whichever is weaker of the non-local ratio and the sampled task count, the same evidence-strength convention as the memory-waste model above). The widget&#39;s always-rendered stacked-area chart (<code>packages/core/src/core-usage-locality.ts</code>) does not depend on the finding. The finding adds a threshold/impact-band section above it, a per-stage non-local breakdown below it (shown whenever any non-local tasks exist at all, independent of whether the aggregate crossed threshold), and a one-line cross-reference to Memory Utilization when its <code>idleCores</code> finding also fired (idle-core ratio itself is not duplicated here).</li></ul><p>Documented ALL-CAPS tag vocabulary: <code>SKEW</code>, <code>SHFL</code>, <code>SPILL</code>, <code>GC</code>, <code>COLD</code>, <code>UTIL</code>, <code>MEM</code> (Memory Utilization), <code>CSTOR</code> (Cache Storage), <code>LOCAL</code> (Core Usage by Locality), plus <code>FAIL</code> (Failed Tasks), <code>JOBS</code> (Job Failures), <code>CFG</code> (Config Audit), <code>PLAN</code> (Plan Advisor), <code>SFAIL</code> (stage failed outright), <code>PART</code> (partition sizing), <code>SLOW</code> (stage overall slowness), <code>SHAPE</code> (stage shape smells), <code>CACHE</code> (caching opportunity), <code>CHRN</code> (autoscaling churn), <code>TINY</code> (tiny tasks), <code>RETRY</code> (retry waste), <code>HOST</code> (slow executor host), <code>STRAG</code> (straggling task), <code>SPEC</code> (speculation waste) and <code>INCMP</code> (incomplete run); <code>packages/core/test/tag-vocabulary.test.js</code> checks the list.</p><p>ETL Phase Attribution (<code>packages/core/src/etl-phases.ts</code> + <code>EtlPhases.tsx</code>), What-If Executor Scaling (<code>packages/core/src/scaling-sim.ts</code> + <code>ScalingSim.tsx</code>, makespan predictions are unvalidated and carry a Model Error indicator), and Compute Efficiency (<code>packages/core/src/efficiency-model.ts</code> + <code>EfficiencyModel.tsx</code>, available vs. used core-hours with <code>wasted-core-hours.ts</code>&#39;s top stages by task core-time, the driver-vs-executor waste split, two theoretical floors, and the right-sizing copy) are main-thread report modules, not <code>DETECTORS</code> entries: descriptive lenses with no impact-band threshold, rendered unconditionally into the Full app report tab rather than participating in the bottleneck catalog. <code>packages/core/src/job-groups.ts</code> (<code>checkConcurrentJobGroups</code>) is likewise a report helper: it flags when concurrent SQL-execution job groups make wall-clock-based estimates unreliable. It feeds the run interpretation&#39;s <code>wallClockReliable</code>; the scaling simulator and the efficiency model read that flag and show a caveat banner rather than suppressing the estimate. The same Full app report grid also holds Evidence Availability and the Core Usage Histogram.</p><h2 id="confidence-metadata" tabindex="-1">Confidence metadata <a class="header-anchor" href="#confidence-metadata" aria-label="Permalink to &quot;Confidence metadata&quot;">​</a></h2><p>Best-effort (non-deterministic) findings carry a <code>confidence</code> + <code>validationRequired</code> marker, rendered by the shared <code>RowStatusCluster</code> (<code>src/view/RowStatusCluster.tsx</code>) when <code>confidence</code> is present and not <code>&#39;high&#39;</code>: a muted &quot;<code>&lt;level&gt;</code> confidence&quot; pill whose accessible tooltip (<code>useAccessibleTooltip</code>: <code>title</code> + <code>aria-describedby</code>, keyboard-focusable) carries <code>validationRequired</code>. Consumers include <code>Spill.tsx</code>, <code>DuplicatePlanSubtree.tsx</code>, <code>SmallFiles.tsx</code>, <code>UnderBroadcast.tsx</code>, <code>OverBroadcast.tsx</code>, <code>CachingOpportunity.tsx</code>, <code>PlanView.tsx</code> and <code>EfficiencyModel.tsx</code>. Spill classification is <code>medium</code> when classified (skew/volume) and <code>low</code> when unclassified; plan-summary warnings are <code>low</code>. Deterministic detectors stay unmarked, treated as high confidence.</p><h2 id="visual-system" tabindex="-1">Visual system <a class="header-anchor" href="#visual-system" aria-label="Permalink to &quot;Visual system&quot;">​</a></h2><p>Tailwind CSS v4 + shadcn/ui (Base UI primitives). The design tokens (colors, including the telemetry-console dark palette) are defined as CSS variables in an <code>@theme inline</code> block and consumed via Tailwind utility classes; there is no hand-authored BEM CSS. Theme polarity: dark = bare <code>:root</code> (no attribute), light = <code>:root[data-theme=&quot;light&quot;]</code>, toggled by <code>src/theme/ThemeProvider.tsx</code> and persisted to <code>localStorage</code>. <code>index.html</code>&#39;s inline FOUC-prevention script runs before React mounts. Mono is the typeface for numerics (<code>--font-mono</code>), applied via a <code>.num</code>-equivalent Tailwind utility per call site rather than one global class. Charts are Recharts (<code>src/view/charts/ChartTheme.tsx</code>&#39;s <code>CHART_COLORS</code>, read from the same CSS tokens). React re-renders on theme toggle, so chart colors update live with the theme. <code>ChartFrame</code> can also expose the underlying rows through a toggleable accessible table and copy them to the clipboard as TSV.</p><h2 id="templating-xss-safe" tabindex="-1">Templating (XSS-safe) <a class="header-anchor" href="#templating-xss-safe" aria-label="Permalink to &quot;Templating (XSS-safe)&quot;">​</a></h2><p>JSX auto-escapes every interpolated value by default. No <code>.innerHTML</code> assignment exists anywhere in the view layer (the one <code>dangerouslySetInnerHTML</code>, shadcn&#39;s <code>ChartStyle</code> in <code>src/components/ui/chart.tsx</code>, only writes CSS variables from the static chart config).</p><p>One Base UI-specific gotcha: <code>WidgetCard.tsx</code> passes <code>aria-expanded={String(open) as &#39;true&#39; | &#39;false&#39;}</code> rather than the raw boolean, because Base UI&#39;s <code>Collapsible.Trigger</code> otherwise overrides a boolean <code>aria-expanded</code> prop with its own internal state via prop merging. The explicit <code>String()</code> cast keeps the rendered attribute in sync with this app&#39;s own <code>open</code> state.</p>',17)])])}const u=o(a,[["render",i]]);export{g as __pageData,u as default};
@@ -1 +0,0 @@
1
- import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const u=JSON.parse('{"title":"Detector contract","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/detector-contract.md","filePath":"contributor-guide/architecture/detector-contract.md"}'),s={name:"contributor-guide/architecture/detector-contract.md"};function c(r,e,n,i,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="detector-contract" tabindex="-1">Detector contract <a class="header-anchor" href="#detector-contract" aria-label="Permalink to &quot;Detector contract&quot;">​</a></h1><p><code>packages/core/src/detectors.ts</code> is the single source of Spark-optimization logic: one declarative <code>DETECTORS</code> entry per pattern, each carrying <code>type</code>, <code>scope</code> (<code>stage</code> / <code>app</code> / <code>config</code> / <code>sql</code>), <code>order</code>, <code>fixEffort</code>, <code>version</code>, a <code>thresholds</code> object, an <code>emits</code> list, an optional <code>docAnchor</code> (every entry but <code>incompleteRun</code> sets one), optional <code>inScorecard</code>, <code>property</code> and <code>suppressedBy</code>, and co-located <code>detect()</code> and <code>estimate()</code> functions. Each finding&#39;s <code>impactBand</code> and recommendation copy are set inside <code>detect()</code>, and so is its <code>remediation</code>: the structured <code>{kind: &#39;conf&#39;, key, direction, suggested}</code> form of the Spark properties that copy names (<code>increaseConf</code>, <code>decreaseConf</code> and <code>setConf</code> in <code>packages/core/src/remediation.ts</code>). A detector adds an entry only for a property its recommendation already names, and leaves <code>suggested</code> null unless it computes the value itself.</p><p>Each entry is built by the helper for its scope: <code>defineStageDetector</code>, <code>defineSqlDetector</code>, <code>defineAppDetector</code> or <code>defineConfigDetector</code>. The helper sets <code>scope</code>, infers the thresholds type from the entry&#39;s own <code>thresholds</code> literal, and types <code>detect</code> as a function property, so its parameters are checked strictly rather than bivariantly:</p><table tabindex="0"><thead><tr><th>Scope</th><th><code>detect</code> signature</th></tr></thead><tbody><tr><td><code>stage</code></td><td><code>detect(stage, ctx, thresholds)</code></td></tr><tr><td><code>sql</code></td><td><code>detect(sqlExec, ctx, thresholds)</code></td></tr><tr><td><code>app</code></td><td><code>detect(ctx, thresholds)</code></td></tr><tr><td><code>config</code></td><td><code>detect(target, thresholds)</code>, where <code>target</code> is <code>{ app }</code></td></tr></tbody></table><p><code>ctx</code> (<code>DetectorCtx</code>) is required, and its <code>app</code> is nullable as on <code>AppModel.app</code>, so a detector that reads the app without a guard, reads a threshold its entry doesn&#39;t declare, or expects another scope&#39;s target fails to compile. <code>detect</code> never reads <code>this</code>. The helper freezes the entry&#39;s <code>thresholds</code> and adds <code>withThresholds(overrides?)</code>, which returns <code>detect</code> with the thresholds bound: the entry&#39;s own, or the caller&#39;s overrides merged over them (see <a href="#tuning-thresholds">Tuning thresholds</a>). Runners such as <code>analyze()</code> call only that.</p><p><code>estimate(finding, ctx)</code> prices one of the entry&#39;s own findings: <code>finding</code> is typed as the <code>Finding</code> member of a type the entry <code>emits</code>, and <code>ctx</code> (<code>EstimateCtx</code>: <code>stages</code>, <code>occupancy</code>, <code>totalCores</code>) is the one occupancy sweep <code>analyze()</code> builds per run. It returns an <code>ImpactEstimate</code> or null (see <a href="./impact-estimation.html">Impact estimation</a>). Every entry must declare one; an entry with no waste model passes <code>noWasteModel</code>. <code>estimateImpact()</code> runs it after suppression, keyed by emitted type. The same <code>EstimateCtx</code> reaches <code>detect()</code> as <code>ctx.impact</code>, so a runtime floor can gate on the estimate the finding will display: <code>skew</code> and <code>straggler</code> build their tail claim once (<code>skewTailClaim</code>/<code>stragglerTailClaim</code>) and both their floor and their <code>estimate()</code> read it through <code>tailClaimImpact</code>. Shared model constants and helpers live in <code>packages/core/src/impact-model.ts</code>.</p><p><code>DETECTORS</code> is declared <code>as const satisfies readonly Detector[]</code>, so each entry keeps its literal <code>type</code> and <code>emits</code>. Two unions derive from it: <code>DetectorType</code> (every entry&#39;s own <code>type</code>) and <code>FindingType</code> (every type an entry&#39;s <code>emits</code> lists, the finding types that actually appear on findings). <code>emits</code> is <code>[type]</code> for every entry except <code>broadcastSizing</code>, whose one plan walk emits <code>overBroadcast</code> and <code>underBroadcast</code> and never its own name. Every per-type lookup keys on the emitted <code>FindingType</code>, never on <code>DetectorType</code>: code that needs the emitted types of an entry reads its <code>emits</code> list. Code that iterates entries generically, such as <code>analyze()</code>, reads them through the <code>Detector</code> type, a union over scopes with the thresholds type erased, <code>detect</code> left off and <code>estimate</code> taking any <code>Finding</code>, so switching on <code>scope</code> narrows the bound function <code>withThresholds()</code> returns.</p><p>How a finding type is presented is registered once, in <code>FINDING_PRESENTATION</code> (<code>packages/core/src/finding-presentation.ts</code>), typed <code>{ [T in FindingType]: FindingPresentation&lt;T&gt; }</code> so the compiler requires exactly one row per emitted type. A row holds the type&#39;s <code>name</code>, board <code>tag</code>, <code>actionLabel(finding)</code>, <code>genericRecommendation(finding)</code> and <code>thresholdSummary(thresholds)</code>, each finding argument typed as that type&#39;s <code>Finding</code> member and <code>thresholds</code> as the emitting entry&#39;s own <code>thresholds</code>. <code>FINDING_NAMES</code>, <code>TYPE_TAG_MAP</code>, <code>getThresholdSummary</code>, <code>findingActionLabel</code> and <code>coreFindingGenericRecommendation</code> all read it. The table sits beside <code>DETECTORS</code> rather than on its entries because the HTML export renders names, tags and labels but may not reach <code>detectors.ts</code> (see <a href="./state-and-history.html#run-interpretation">Run interpretation</a>); its import from <code>detectors.ts</code> is type-only. A detector&#39;s <code>scope</code> and <code>order</code> reach renderers through <code>detectorInfoByType()</code> (<code>detector-docs.ts</code>), keyed by emitted type, which the run interpretation ships; the Alerts clean-check grouping reads <code>detectors[type].scope</code>.</p><p>Each finding type has its own shape in <code>packages/core/src/finding-types.ts</code>: <code>Finding</code> is a union discriminated on <code>type</code>, and a compile-time check in <code>detectors.ts</code> fails when its members and <code>FindingType</code> differ. Each member splits into a <code>&lt;Type&gt;Evidence</code> interface, the fields the evidence report publishes (listed again in <code>EVIDENCE_KEYS</code> in <code>evidence-report.ts</code>, checked both ways), and fields declared only on <code>&lt;Type&gt;Finding</code>, which other core modules read but the report never publishes. <code>value</code> is always a magnitude; a text-valued finding (<code>stageFailed</code>, <code>configAudit</code>, <code>incompleteRun</code>) sets <code>valueText</code> instead. So a new detector type needs an <code>emits</code> entry, an <code>estimate()</code> on that entry that covers it, a <code>finding-types.ts</code> member, an <code>EVIDENCE_KEYS</code> entry, an <code>ID_DISCRIMINATORS</code> entry in <code>analyzer.ts</code>, a <code>FINDING_PRESENTATION</code> row and a view <code>REGISTRY</code> entry. The compiler reports each one that is missing. Tests and process, not the compiler, cover the rest: a new board tag goes in AGENTS.md&#39;s tag list (<code>packages/core/test/tag-vocabulary.test.js</code> fails otherwise), an intended finding change is checked with <code>node dev/bench-analyze.mjs --check dev/corpus-snapshot.json</code> and then <code>--update</code>d, and the PR needs a <code>.changeset/*.md</code>. A <code>docAnchor</code> must have a section in the detection docs (<code>packages/core/test/docs-config.test.js</code>). The view narrows with <code>findingsOfType(catalog, type)</code> (<code>packages/core/src/findings-of-type.ts</code>) rather than re-declaring a finding&#39;s fields.</p><p>Both consumers are thin loops over that array:</p><ul><li><p><code>packages/core/src/analyzer.ts</code>: <code>analyze()</code> runs every entry regardless of scope, skipping only <code>inScorecard:false</code> ones, then applies <code>suppressedBy</code> (below); <code>auditConfig()</code> separately runs the <code>scope:&#39;config&#39;</code> entries. The four <code>configAudit</code> entries stay out of the bottleneck catalog because each sets <code>inScorecard:false</code>, not because of <code>scope:&#39;config&#39;</code>: a config-scope detector without that flag would run through <code>analyze()</code> too. Each finding is stamped with its entry&#39;s <code>docAnchor</code>.</p></li><li><p><code>src/view/detector-registry.tsx</code>: <code>REGISTRY</code> maps each emitted finding type to <code>{component, region, widgetId, routeable}</code> (view-only concerns), checked with <code>satisfies Record&lt;FindingType, RegistryEntry&gt;</code>. Each type has its own component, and no two entries share one. The executor-count chart (<code>ExecutorCountChart</code>) is a <code>ReferenceSection</code> tile, not a <code>REGISTRY</code> entry. <code>orderedWidgets(detectors)</code> takes the run interpretation&#39;s <code>detectorInfoByType()</code> record (<code>DetectorInfo</code> per type), keeps the types that have a <code>REGISTRY</code> entry, and sorts <code>action</code>-region components before <code>reference</code>-region ones, then by ascending <code>order</code>, using the record&#39;s declaration order to break ties.</p><p>Each widget component receives the full catalog and self-gates when it has nothing to show, rendering <code>null</code> or a muted &quot;no issue&quot; card for the always-visible ones. <code>orderedWidgets()</code> itself has no empty/non-empty branching, since it iterates the static per-type detector info, not the runtime <code>catalog</code>.</p></li></ul><p>Default thresholds live only in each entry&#39;s <code>thresholds</code>; see <a href="#bottleneck-thresholds">Bottleneck thresholds</a>. Only the CLI and the MCP server can override them, per run: see <a href="#tuning-thresholds">Tuning thresholds</a>.</p><h2 id="confidence-disclosure" tabindex="-1">Confidence disclosure <a class="header-anchor" href="#confidence-disclosure" aria-label="Permalink to &quot;Confidence disclosure&quot;">​</a></h2><p>A <code>Finding</code> may carry <code>confidence: &#39;low&#39; | &#39;medium&#39; | &#39;high&#39;</code> plus a <code>validationRequired</code> string. <code>RowStatusCluster</code> (<code>src/view/RowStatusCluster.tsx</code>) renders it on widget rows (the verdict&#39;s steps print their own &quot;verify before acting&quot; line), gated to Advanced density: a plain &quot;&lt;confidence&gt; confidence&quot; badge whose tooltip carries the full <code>validationRequired</code> text. A finding with no <code>confidence</code> field renders identically to a fully-validated one, so every detector whose thresholds are our own unvalidated noise floor (marked <code>NOT SOURCED</code> in a code comment) should set both fields, not just the ones that happen to already have <code>RowStatusCluster</code> wired into their widget. <code>skew</code>, <code>straggler</code>, and <code>gc</code> set <code>confidence</code> for exactly this reason: their runtime-floor thresholds carry the same kind of unvalidated-noise-floor caveat <code>coreLocality</code>, <code>autoscalingChurn</code>, and <code>memoryUtilization</code>&#39;s <code>wasteModel</code> variant already disclose. None of these hardcode a single confidence value: each scales <code>&#39;low&#39; | &#39;medium&#39; | &#39;high&#39;</code> off how far the finding sits past its own detector&#39;s threshold, via a small named helper placed just above the <code>DETECTORS</code> array (e.g. <code>skewConfidence</code>, <code>coreLocalityConfidence</code>, <code>cachingReuseConfidence</code>) rather than an inline literal.</p><h2 id="the-fixeffort-field" tabindex="-1">The <code>fixEffort</code> field <a class="header-anchor" href="#the-fixeffort-field" aria-label="Permalink to &quot;The `fixEffort` field&quot;">​</a></h2><p>Each <code>Detector</code> entry also carries <code>fixEffort: &#39;config&#39; | &#39;code&#39; | &#39;rearchitect&#39;</code>, alongside <code>order</code> and <code>thresholds</code>: a rough estimate of how much work resolving the finding takes.</p><p>No view reads it: the ranking <code>FixTheseFirst</code> (<code>src/view/widgets/FixTheseFirst.tsx</code>) renders is computed in core by <code>interpretRun</code> (<code>rankedRollup</code>/<code>rankFindings</code> in <code>packages/core/src/recommendation-rollup.ts</code>), by estimate tier and impact, never by <code>fixEffort</code>. See <a href="./widget-rendering.html#widget-rendering-order">Widget rendering order</a> for how it ranks findings.</p><p>Two shared helpers back multiple detectors and reports. <code>packages/core/src/plan-tree-walk.ts</code>&#39;s <code>walkPlanTree(root, visit, {dedupe})</code> is the iterative pre-order plan-tree traversal used by <code>detectors.ts</code>, <code>plan-summary.ts</code>, <code>plan-duration-attribution.ts</code>, <code>plan-dot.ts</code> and <code>plan-graph-model.ts</code>. <code>packages/core/src/core-count.ts</code> holds the shared core-count logic. Its <code>computePeakConcurrentCores</code>/<code>computePeakConcurrentExecutorCount</code> sweeps back <code>detectors.ts</code>&#39;s <code>utilization</code> and <code>memoryUtilization</code> entries, <code>efficiency-model.ts</code> and <code>wasted-core-hours.ts</code>, so the Scorecard&#39;s Unused core time and the verdict&#39;s idle figure share one capacity. <code>computeTotalCores(app, executorsAdded)</code>, used only by <code>scaling-sim.ts</code>, sums every <code>ExecutorAdded</code> event with no regard for overlap, so under executor churn (spot preemption, <code>dynamicAllocation</code> replacement) it double-counts a churned executor&#39;s capacity against its replacement&#39;s; the peak-concurrent sweeps don&#39;t.</p><h2 id="cross-detector-suppression" tabindex="-1">Cross-detector suppression <a class="header-anchor" href="#cross-detector-suppression" aria-label="Permalink to &quot;Cross-detector suppression&quot;">​</a></h2><p>An entry may name another entry&#39;s <code>type</code> in <code>suppressedBy</code>. Once every detector has run, <code>analyze()</code>&#39;s <code>applySuppression()</code> drops each of that entry&#39;s findings on a stage where the named detector emitted a finding. It reads the unsuppressed findings, so neither the two entries&#39; declaration order nor the order suppressions apply in changes the result, and <code>order</code> stays a display field only. A compile-time check in <code>detectors.ts</code> (<code>SuppressorsAreDetectors</code>) fails when <code>suppressedBy</code> names no entry.</p><p><code>stageSlowness</code> sets <code>suppressedBy: &#39;slowHost&#39;</code>: a stage <code>slowHost</code> already explains needs no generic &quot;this stage is slow&quot; finding. Suppression follows what <code>slowHost</code> actually emitted, so a run whose <code>slowHost</code> thresholds are tuned so it can&#39;t fire gets its <code>stageSlowness</code> findings back. The mechanism is deliberately minimal (same-stage, one named suppressor per entry), not a general dependency graph. <code>auditConfig()</code> doesn&#39;t apply it, since no <code>scope:&#39;config&#39;</code> entry sets <code>suppressedBy</code>.</p><h2 id="tuning-thresholds" tabindex="-1">Tuning thresholds <a class="header-anchor" href="#tuning-thresholds" aria-label="Permalink to &quot;Tuning thresholds&quot;">​</a></h2><p><code>analyze()</code>&#39;s eighth argument is <code>{ thresholds?: ThresholdOverrides }</code>: per-entry overrides keyed by entry <code>type</code>, each a partial of that entry&#39;s own <code>thresholds</code>. Omitted, every entry runs its defaults; the dashboard never passes it. The CLI&#39;s and the MCP server&#39;s <code>--thresholds &lt;file&gt;</code> read a JSON file of that shape (<code>packages/core/src/cli/threshold-config.ts</code>) and validate it with <code>parseThresholdOverrides()</code> (<code>packages/core/src/threshold-overrides.ts</code>), which refuses an unknown detector or threshold, a negative or non-numeric value, a tier table of a different length or out of ascending order, an entry with no thresholds (<code>stageFailed</code>, <code>incompleteRun</code>), and any <code>configAudit</code> (config-scope) override: those checks compare against Spark&#39;s own defaults, so there is nothing to tune. A file that can&#39;t be read or parsed refuses the run the same way. The <a href="./../../user-guide/getting-started.html#tuning-detector-thresholds">user guide</a> documents the file.</p><p>A finding from an entry whose overrides move a threshold off its default carries <code>tunedThresholds</code> (<code>{ &lt;name&gt;: { value, default } }</code>), and once its estimate is attached the analyzer appends a caveat to its <code>validationRequired</code> naming the tuned values. When the finding has an estimate figure (wall-clock or raw waste), the caveat adds that impact estimates are calibrated against the default thresholds (see <a href="./impact-estimation.html">Impact estimation</a>), so its estimate is unvalidated; an informational finding gets the label alone. An override equal to the default labels nothing. Tuning a <code>suppressedBy</code> target changes which of the suppressed entry&#39;s findings survive, so those findings carry the suppressor&#39;s tuned thresholds too, named <code>&lt;suppressor&gt;.&lt;name&gt;</code> (e.g. <code>slowHost.minHosts</code> on <code>stageSlowness</code>). Only that one link is followed. The evidence report repeats the label on the finding row, the clean check, the <code>detectors</code> catalog row (whose <code>thresholds</code> are then the effective ones) and in <code>summary.tunedThresholds</code>; see <a href="./worker-protocol.html#portable-evidence-report">Portable evidence report</a>.</p><p>A tuned <code>floorPctWarn</code>/<code>floorPctCrit</code> on <code>skew</code> or <code>straggler</code> also grades that entry&#39;s own findings in <code>deriveImpactBand</code> (<code>impact-band.ts</code>); every other finding keeps the run-wide default floors. <code>--max-skew</code> recomputes the ratio with the run&#39;s effective <code>minTasksForP95</code>, so the budget measures the same ratio the skew finding reports. Caveat text that names a threshold (<code>gc</code>, <code>skew</code>, <code>straggler</code>, <code>memoryUtilization</code>, <code>coreLocality</code>) and the <code>broadcastSizing</code> over-broadcast recommendation state the value the detector ran with. The HTML export still renders the default-threshold analysis.</p><h2 id="per-operator-duration-attribution" tabindex="-1">Per-operator duration attribution <a class="header-anchor" href="#per-operator-duration-attribution" aria-label="Permalink to &quot;Per-operator duration attribution&quot;">​</a></h2><p><code>packages/core/src/plan-duration-attribution.ts</code> (entry <code>attributeStageDurationToPlan(planTree, stagesById, sqlExec)</code>) approximates how a SQL execution&#39;s stage wall-time splits across plan operators, returning a <code>Map&lt;planNode, milliseconds&gt;</code>. It cuts the plan tree at Exchange boundaries into connected components: because <code>resolvePlanTree</code> in <code>event-handlers.ts</code> always synthesizes a <code>read</code> node wrapping a <code>write</code> node for every raw <code>Exchange</code>/<code>BroadcastExchange</code>, the cut is keyed off <code>PlanNode.exchangeRole === &#39;read&#39;</code> on the parent, not a name regex: the write half starts the new component, the read half stays in its parent&#39;s. A node with no <code>exchangeRole</code> at all (for example <code>ReusedExchange</code>, which is never split) never starts a new component on its own. Each component receives a stable pre-order identity and separate parent/depth/traversal metadata; the identity itself does not encode its count of Exchange ancestors. It zips components deepest-first by that explicit depth against submission-ordered stage IDs, then apportions each matched stage&#39;s wall-time across that component&#39;s nodes by timing-metric weight, falling back to an even split when no node carries a timing metric.</p><p>This is best-effort inference, not measurement. Spark&#39;s event model exposes no ground truth for per-operator time within a stage; the Exchange-boundary segmentation and deepest-component-to-earliest-stage zip are heuristics. Treat the per-operator numbers as directional hints, never as authoritative timings, and do not build hard thresholds or findings on top of them.</p><h2 id="stage-id-attribution-for-plan-advisor-findings" tabindex="-1">Stage-ID attribution for Plan Advisor findings <a class="header-anchor" href="#stage-id-attribution-for-plan-advisor-findings" aria-label="Permalink to &quot;Stage-ID attribution for Plan Advisor findings&quot;">​</a></h2><p>The Plan Advisor detectors (<code>duplicatePlanSubtree</code>, <code>smallFiles</code>, <code>broadcastSizing</code> in <code>packages/core/src/detectors.ts</code>) each attribute their finding to a narrowed <code>stageIds</code> set rather than the whole SQL execution: <code>PlanNode.stageIds</code> is resolved once per plan tree at parse time by unioning, per node, every metric&#39;s accumulator ID against a <code>taskAccumStages: Map&lt;accumulatorId, Set&lt;stageId&gt;&gt;</code> built while parsing <code>TaskEnd</code> events, then clipping the result to the execution&#39;s own stage set. An accumulator ID occasionally points to a <em>different</em> execution&#39;s stages, e.g. a <code>ReusedSubquery</code> computed once and reused verbatim, and the clip prevents misattributing that other execution&#39;s work. Each detector unions its implicated node(s)&#39; <code>stageIds</code> and falls back to the execution-wide set only when no implicated node has any coverage; a finding never partially blends a narrowed set with the execution-wide one. When an execution has no stage universe at all (no jobs ever recorded against it, which is true for 42% of real-log SQL executions with a plan tree, typically job-less/driver-only executions), the clip drops every candidate stage ID instead of passing them through: every node in that execution&#39;s tree ends up with no <code>stageIds</code> anywhere, same &quot;coverage is partial&quot; framing as below. (The clip is unconditional on <code>executionStageIds</code> being present: treating &quot;no stage universe&quot; as &quot;no clip&quot; would let a foreign accumulator ID collision, e.g. the <code>ReusedSubquery</code> case above, leak another execution&#39;s stages into a job-less execution&#39;s nodes.)</p><p>Coverage is partial by Spark&#39;s own design: whole-stage-codegen wrapper nodes (<code>InputAdapter</code>, and other purely structural passthrough markers) carry no accumulators at all, and <code>BroadcastExchangeExec</code>&#39;s own metrics are computed entirely on the driver and never appear on any <code>TaskEnd</code> (real Spark behavior). Those driver-computed metrics live specifically on the synthesized <em>write</em> half (<code>exchangeRole: &#39;write&#39;</code>); the <em>read</em> half always carries <code>metrics: []</code>. The write half&#39;s immediate child, which does carry executor-side metrics, is unioned in instead, see <code>overBroadcast</code>&#39;s wiring. A <code>TaskEnd</code> arriving after its stage has already been finalized is also silently excluded from <code>taskAccumStages</code>, consistent with the parser&#39;s existing out-of-order tolerance elsewhere.</p><p><code>planTree</code> itself is kept current against Spark&#39;s adaptive query execution (AQE) re-plans: <code>SparkListenerSQLAdaptiveExecutionUpdate</code> events overwrite the execution&#39;s <code>sparkPlanInfo</code> last-write-wins, so accumulator-ID evidence is matched against the plan that actually ran rather than a stale pre-AQE snapshot. Since a superseded plan is never read, <code>dispatchLine</code> holds an open execution&#39;s latest update as unparsed text and parses only that one, when <code>SQLExecutionEnd</code> arrives or at parse completion (<code>deferAdaptiveUpdate</code>, <code>event-handlers.ts</code>). It recognizes an update from the flat first and last pieces <code>buildChunkDecoder</code> reports for a line joined across slices (<code>JoinedLine</code>), so a superseded update is never copied into one flat string either. The raw <code>sparkPlanInfo</code> stays worker-side and is released once <code>SQLExecutionEnd</code> resolves it into <code>planTree</code>; <code>physicalPlanDescription</code> (Spark&#39;s text rendering of the plan, which nothing reads) is emptied before <code>JSON.parse</code> and never retained (<code>stripPlanDescription</code>, <code>event-handlers.ts</code>). <code>buildChunkDecoder</code> decodes each decompressed chunk in slices of at most 512 KiB, and drops the bytes of a value that crosses a slice boundary without decoding them.</p><p>No eviction/pruning is added to <code>taskAccumStages</code>, a deliberate choice, not an oversight: measured on real logs, it holds roughly 1,050 keys per compressed MB (9,850 keys on an 11.6 MB fixture, about 29,000 keys on a 28.1 MB fixture). Extrapolated to a 240MB+ log, the scale this tool targets, that is roughly 250,000 keys, around 45 MB of heap for an equivalent synthetic <code>Map&lt;number, Set&lt;number&gt;&gt;</code>. This heap estimate is small relative to this tool&#39;s other in-memory state.</p><p>Per-execution pruning (e.g. dropping a <code>taskAccumStages</code> entry once its stage finalizes or its owning SQL execution resolves, mirroring how <code>accumState</code> is cleared in <code>endSqlExecution</code>) is deliberately not done either: unlike <code>accumState</code>, <code>taskAccumStages</code> is one global, un-scoped map read by every execution&#39;s <code>resolvePlanTree</code> call, and the <code>ReusedSubquery</code> case above depends on a stage recorded under one execution still being visible when a later execution resolves. Pruning on any single execution&#39;s lifecycle would break that cross-execution lookup. What is bounded is the growth from a single pathological event: <code>TaskEndEventSchema</code>&#39;s <code>Accumulables</code> array is capped at <code>MAX_ACCUMULABLES_PER_TASK</code> (10,000, <code>event-schemas.ts</code>), well above any real plan&#39;s per-task metric count, so a single crafted <code>TaskEnd</code> can&#39;t grow the map past that per-event bound; a <code>TaskEnd</code> exceeding it fails schema validation and the line is skipped (counted in <code>skippedLines</code>) like any other malformed event. Each accumulable is checked for its numeric <code>ID</code> only: <code>Update</code>/<code>Value</code> are unread, and Spark writes some as JSON arrays (<code>internal.metrics.updatedBlockStatuses</code>), which a stricter check would reject along with the whole task. When every entry has Spark&#39;s flat <code>{&quot;ID&quot;:n,...}</code> form, <code>parseTaskEnd</code> (<code>event-handlers.ts</code>) reads the IDs with a string scan and cuts the array out before <code>JSON.parse</code>; any other shape is parsed whole.</p><h2 id="bottleneck-thresholds" tabindex="-1">Bottleneck thresholds <a class="header-anchor" href="#bottleneck-thresholds" aria-label="Permalink to &quot;Bottleneck thresholds&quot;">​</a></h2><p>Every change to <code>packages/core/src/detectors.ts</code> should reference this table.</p><p>Every finding&#39;s <code>impactBand</code> comes from one of two places. For any finding whose <code>impactEstimate</code> carries a <code>wallClock</code> estimate (the common case for most rules below), <code>analyzer.ts</code> calls <code>deriveImpactBand()</code> (<code>packages/core/src/impact-band.ts</code>) immediately after <code>estimateImpact()</code>, which sets <code>.impactBand</code> purely from <code>wallClock.high</code> as a fraction of the app&#39;s total duration (<code>&gt;= 2%</code> critical, <code>&gt;= 0.5%</code> warning, else info: <code>IMPACT_FLOOR_PCT_CRIT</code>/ <code>IMPACT_FLOOR_PCT_WARN</code>, which <code>skew</code>&#39;s <code>floorPctWarn</code> and <code>straggler</code>&#39;s <code>floorPctWarn</code>/<code>floorPctCrit</code> default to, so a tail those gates admit grades at least warning; a tuned <code>skew</code> or <code>straggler</code> floor also grades that entry&#39;s own findings, see <a href="#tuning-thresholds">Tuning thresholds</a>). For those rules, the table below documents their firing gate plus their fixed fallback constant, which surfaces only when this run&#39;s finding of that type didn&#39;t get a wallClock estimate (a stage excluded from the occupancy sweep). For rules whose finding type never gets a wallClock estimate (<code>resourceOnly</code>/<code>informational</code> basis, e.g. <code>configAudit</code>, or a rule that keeps its own ratio-tiered classification, e.g. <code>failures</code>), the full threshold table below is the real, displayed classification: <code>detectors.ts</code> sets <code>impactBand</code> directly and nothing overwrites it. <code>partitionSizing</code>&#39;s <code>maxPartitionTooBig</code> rule is a third case: it does carry a <code>wallClock</code> estimate but is explicitly exempted in <code>deriveImpactBand()</code> because it&#39;s a hardcoded-critical OOM/crash-risk safety signal, not a time-recovery one, so <code>detectors.ts</code>&#39;s own classification stands regardless of how small that estimate is relative to the run.</p><h3 id="fixed-fallback-only-usually-wallclock-derived-instead" tabindex="-1">Fixed fallback only (usually wallClock-derived instead) <a class="header-anchor" href="#fixed-fallback-only-usually-wallclock-derived-instead" aria-label="Permalink to &quot;Fixed fallback only (usually wallClock-derived instead)&quot;">​</a></h3><p>These rules have no <em>band</em> tiers in <code>packages/core/src/detectors.ts</code> (<code>deriveImpactBand</code> sets the band whenever a wallClock estimate is available); the constant in the last column is only a floor-case fallback. Their <em>firing</em> gate lives in each entry&#39;s <code>thresholds</code> object: it decides whether the rule reports anything, so it stays documented here in full.</p><table tabindex="0"><thead><tr><th>Rule</th><th>Fires when</th><th>Fallback</th></tr></thead><tbody><tr><td>Task skew</td><td><code>taskDurationP95 / taskDurationP50 &gt; 3×</code> (<code>taskDurationMax / P50</code> for stages under <code>minTasksForP95</code> = 20 tasks), <strong>and</strong> the occupancy-clipped tail recovery (<code>tailRecoveryMs</code>: <code>finalizeStage</code>&#39;s task-level replay of the stage with every task over 4× P50 capped at P50) is ≥ <code>floorPctWarn</code> = 0.5% of app runtime. The clip floors the claim at the longest task the fix leaves, not the current one, and at the core work the fix leaves over every core (see impact-estimation.md&#39;s occupancy section); <code>straggler</code>&#39;s floors use the same clip</td><td><code>warning</code></td></tr><tr><td>Shuffle read</td><td><code>shuffleReadBytes &gt; minBytes</code> = 50 MiB, on a stage that isn&#39;t shorter than <code>stageFloorPct</code> = 0.5% of the run (a zero-length stage or an unknown run duration passes; the claim is clipped to the stage, so a shorter one would grade <code>info</code>, the shuffle itself still there)</td><td><code>info</code></td></tr><tr><td>Partition sizing: skew</td><td><code>shuffleReadMax &gt; 5×</code> <code>shuffleReadP50</code> <strong>and</strong> <code>shuffleReadMax &gt; 256 MiB</code></td><td><code>warning</code></td></tr><tr><td>Partition sizing: low parallelism</td><td><code>shuffleReadBytes ≥ 1 GiB</code> <strong>and</strong> <code>taskCount ≤ 7</code></td><td><code>warning</code></td></tr><tr><td>Partition sizing: oversized partition</td><td><code>shuffleReadMax ≥ 5 GiB</code></td><td><code>critical</code></td></tr><tr><td>GC</td><td><code>executorRunTime ≥ minRunTimeMs</code> = 10 s <strong>and</strong> <code>gcPct &gt; 10%</code></td><td><code>warning</code></td></tr><tr><td>GC (low / cost)</td><td><code>executorRunTime ≥ 10 s</code> <strong>and</strong> <code>gcPct &lt; lowInfoPct100</code> = 5% (checked only when the GC row above did not fire) <strong>and</strong> the stage lasts ≥ <code>lowInfoFloorPct</code> = 0.5% of the run (passes when the run&#39;s duration is unknown or the stage has zero length, such as one with no completion time). Gets no wall-clock estimate (an over-provisioning signal), so this band always stands. The floor drops low-GC notes from stages that barely ran: low GC is still true on those stages, but a memory-sizing note from a stage that barely ran adds nothing</td><td><code>info</code></td></tr><tr><td>Spill</td><td>any non-zero <code>memoryBytesSpilled</code>, on a stage that isn&#39;t shorter than <code>stageFloorPct</code> = 0.5% of the run (as for shuffle read: a shorter stage would grade <code>info</code>). The magnitude sub-table below classifies <em>how much</em>, but does not gate firing</td><td><code>warning</code></td></tr><tr><td>Cold start</td><td><code>firstExecutorAddedAt − firstStageSubmittedAt &gt; gapSeconds</code> = 30 s (no finding without executor-added events): the time a runnable stage waited for its first executor. An executor added before the first stage and still alive at submission means no wait and no finding; one removed at or before submission is ignored, so the gap runs to the next executor added after it.</td><td><code>warning</code></td></tr><tr><td>Slow host: mean-duration ratio</td><td>stage has ≥ <code>minHosts</code> = 3 hosts (or executors) and ≥ <code>minTasks</code> = 15 tasks, and lasts ≥ <code>stageFloorPct</code> = 0.5% of the run (passes when the run&#39;s duration is unknown or the stage has zero length, which gets no estimate and keeps its fallback band; every slow-host finding on a shorter stage would grade <code>info</code>, the imbalance itself still true; this gate covers every slowHost row); then per host: mean task duration / overall median ≥ <code>ratioWarn</code> = 2.0× <strong>and</strong> host task-share ≥ <code>minShare</code> = 20% <strong>and</strong> host mean ≥ <code>floorMs</code> = 1000 ms (absolute-magnitude floor, rules out sub-second noise)</td><td><code>warning</code></td></tr><tr><td>Slow host: duration-share</td><td>same stage gate as the row above; then per host: ≥ <code>shareWarn</code> = 75% of the stage&#39;s total task-duration <strong>and</strong> ≥ <code>taskShareWarn</code> = 50% of its task count</td><td><code>warning</code></td></tr><tr><td>Stage slowness: absolute fallback, suppressed when <code>slowHost</code> already fired</td><td>stage wall-clock duration ≥ <code>infoMin</code> = 15 min. The band then comes from the partitioning-headroom estimate (see impact-estimation.md), not the duration</td><td><code>info</code></td></tr><tr><td>Straggler / speculative-execution</td><td><code>taskCount ≥ minTasks</code> = 10, <strong>and</strong> the stage lasts ≥ <code>floorPctWarn</code> = 0.5% of the run (passes when the run&#39;s duration is unknown or the stage has zero length, such as one with no completion time; a shorter stage&#39;s tail can&#39;t cost more than its own duration, so every finding there would grade <code>info</code>, the slow tail itself still true), <strong>and</strong> either any speculative task ran, <strong>or</strong> straggler share &gt; <code>shareWarn</code> = 5%, <strong>or</strong> straggler share &gt; <code>shareWarnAtFloor</code> = 2.5% with the occupancy-clipped tail recovery (<code>tailRecoveryMs</code>, as for skew) already ≥ <code>floorPctWarn</code> (0.5% of app runtime). Skew&#39;s <code>ratioWarn</code> = 3 and the 0.5% floor are the values the task-level tail replay was tuned against. <code>warnPct</code>/<code>critPct</code> (10%/20% speculative share) and <code>floorPctWarn</code>/<code>floorPctCrit</code> (0.5%/2% of app runtime) do not set the band; they rank the straggler-vs-speculative tiers that pick which <em>metric</em> the finding reports</td><td><code>info</code></td></tr><tr><td>Speculation waste</td><td><code>speculationWastedAttempts ≥ minWasted</code> = 5 <strong>and</strong> <code>speculationWasteMs ≥ minWasteMs</code> = 60 s</td><td><code>warning</code></td></tr><tr><td>Retry waste</td><td><code>wastedAttempts ≥ minWasted</code> = 3 <strong>and</strong> <code>retryWasteMs ≥ minWasteMs</code> = 30 s (attempts superseded by a later retry of the same task)</td><td><code>warning</code></td></tr><tr><td>Tiny tasks</td><td><code>taskCount ≥ minTasks</code> = 100 <strong>and</strong> <code>taskDurationP50 ≤ maxP50</code> = 500 ms <strong>and</strong> <code>taskDurationP95 ≤ maxP95</code> = 1000 ms <strong>and</strong> the stage lasts ≥ <code>stageFloorPct</code> = 0.5% of the run (passes when the run&#39;s duration is unknown or the stage has zero length; coalescing can&#39;t save more than the stage&#39;s own duration, so every finding on a shorter stage would grade <code>info</code>, the tasks still tiny). Checked against each stage&#39;s measured per-task overhead (task wall time minus executor run time, the estimate&#39;s own input): a stage with 100+ tasks outside the P50/P95 gate would not save 0.5% of its run by coalescing, while one inside it can, and grades above <code>info</code></td><td><code>info</code></td></tr><tr><td>Duplicate plan subtree</td><td>a subtree of ≥ <code>minSubtreeSize</code> = 3 nodes whose shape fingerprint repeats ≥ <code>minOccurrences</code> = 2× in the plan, unless its linked stages together lasted less than <code>stageFloorPct</code> = 0.5% of the run (a repeat with no linked stage time, or an unknown run duration, is kept; the claim counts at most each stage&#39;s own task-active time, so such a repeat would grade <code>info</code>, the repeat still in the plan). <code>occurrencesIdentical</code> records whether the repeats also agree node-for-node on normalized detail, ignoring AQE query-stage numbers; when they don&#39;t (same shape over another table, filter or projection) the finding is <code>info</code> with confidence <code>low</code> and no time claim. <code>stageShares</code> gives, per stage, the repeated operators&#39; share of the stage&#39;s operators (WholeStageCodegen wrappers and Exchange write halves not counted); with no attributed stage the finding is <code>info</code></td><td><code>warning</code></td></tr><tr><td>Small files read/write</td><td>per read/write side: file count &gt; <code>minFiles</code> = 100 <strong>and</strong> average file size &lt; <code>maxAvgFileSizeMB</code> = 3 MiB</td><td><code>warning</code></td></tr><tr><td>Broadcast sizing: missed</td><td>a 2-child <code>SortMergeJoin</code> whose smaller side is &lt; 10 MiB (unconditional), or &lt; 100 MiB with the larger side &gt; 10 GiB, or &lt; 1 GiB with larger &gt; 300 GiB, or &lt; 5 GiB with larger &gt; 1 TiB (<code>broadcastTiers</code> × <code>comparisonTiers</code>)</td><td><code>info</code></td></tr><tr><td>Broadcast sizing: oversized</td><td>a <code>BroadcastExchange</code> node whose <code>data size</code> metric &gt; <code>overBroadcastBytes</code> = 1 GiB</td><td><code>warning</code></td></tr></tbody></table><h4 id="spill-magnitude-tiers" tabindex="-1">Spill magnitude tiers <a class="header-anchor" href="#spill-magnitude-tiers" aria-label="Permalink to &quot;Spill magnitude tiers&quot;">​</a></h4><p>The spill row&#39;s band is a fixed <code>warning</code> fallback, but <code>computeSpillMagnitude</code> (<code>packages/core/src/detectors.ts</code>) runs on every spill finding and sets its <code>spillMagnitude</code> field, which the Spill widget displays. Its tiers, in evaluation order (first match wins, <code>null</code> when nothing matches):</p><table tabindex="0"><thead><tr><th>Condition</th><th>Magnitude</th></tr></thead><tbody><tr><td>Single-task stage: <code>spillDiskMax ≥ singleTaskDiskGiB</code> = 1 GiB <strong>or</strong> <code>spillMemMax ≥ singleTaskMemGiB</code> = 4 GiB</td><td><code>severe</code></td></tr><tr><td>Multi-task stage: <code>spillDiskMax ≥ highDiskGiB</code> = 1 GiB, <strong>or</strong> <code>spillDiskMax / taskCount ≥ highTaskDiskMB</code> = 512 MiB (per-task proxy), <strong>or</strong> <code>spillMemMax ≥ highMemGiB</code> = 4 GiB</td><td><code>high</code></td></tr><tr><td>Multi-task stage: <code>spillDiskMax ≥ medDiskMB</code> = 256 MiB <strong>or</strong> <code>spillMemMax ≥ medMemGiB</code> = 1 GiB</td><td><code>medium</code></td></tr><tr><td>Skew (<code>taskCount ≥ skewMinTasks</code> = 10): <code>spillDiskMax / spillDiskP50 &gt; skewRatio</code> = 5× <strong>and</strong> <code>spillDiskMax ≥ skewDiskFloorMB</code> = 128 MiB</td><td><code>high</code></td></tr><tr><td>Skew (<code>taskCount ≥ 10</code>): <code>spillMemMax / spillMemP50 &gt; 5×</code> <strong>and</strong> <code>spillMemMax ≥ skewMemFloorMB</code> = 256 MiB</td><td><code>medium</code></td></tr></tbody></table><p>Disk spill is weighted worse than memory spill by design: the memory thresholds sit well above their disk counterparts at every tier.</p><h3 id="full-threshold-table-never-wallclock-derived" tabindex="-1">Full threshold table (never wallClock-derived) <a class="header-anchor" href="#full-threshold-table-never-wallclock-derived" aria-label="Permalink to &quot;Full threshold table (never wallClock-derived)&quot;">​</a></h3><table tabindex="0"><thead><tr><th>Rule</th><th>Warning</th><th>Critical</th></tr></thead><tbody><tr><td>Stage shape: PRatio</td><td><code>taskCount / totalCores &lt; 0.5</code> (info, under-parallelized), on a stage lasting ≥ <code>lowParallelismFloorPct</code> = 0.5% of the run (passes when the run&#39;s duration is unknown or the stage has zero length): parallelizing can&#39;t save more than the stage&#39;s duration, so a shorter stage would fire below the floor</td><td>none</td></tr><tr><td>Stage shape: OIRatio</td><td><code>outputBytes / inputBytes &gt; 10×</code> (info, data explosion)</td><td>none</td></tr><tr><td>Stage shape: TaskStageSkew</td><td><code>taskDurationMax / stageDuration &gt; stageShareMin</code> = 0.5 <strong>and</strong> <code>taskDurationMax / taskDurationP50 &gt; skewWarn</code> = 3× (info), on a multi-task stage lasting ≥ <code>taskStageSkewFloorPct</code> = 0.5% of the run. A task runs inside its stage, so the share is at most 1; the median gate is needed because on a single wave of even tasks the longest one spans the whole stage too</td><td>none</td></tr><tr><td>Failed tasks</td><td>failure rate &gt; 5% (min 10 tasks)</td><td>&gt; 20%</td></tr><tr><td>Stage failed outright</td><td>none</td><td>any <code>stageFailureReason</code> present</td></tr><tr><td>Slow host: multi-dimensional</td><td>max/median ratio across taskTime/inputBytes/shuffleBytes/storageMemory ≥ 1.33× (info); each dimension&#39;s sample must also clear an absolute floor (1000 ms for taskTime, 64 MiB for the byte dimensions). A stage lasting under <code>stageFloorPct</code> = 0.5% of the run is skipped (see the mean-duration row): a byte dimension there has no time estimate and would otherwise keep its ratio tier (a shorter stage&#39;s byte dimension would otherwise keep a warning or critical tier). The taskTime dimension falls back to <code>info</code> and takes its band from its wall-clock estimate; the byte dimensions get no time estimate and keep the ratio tier</td><td>≥ <code>ratioTiers[1]</code> = 1.78× warning, ≥ <code>ratioTiers[3]</code> = 10× critical (<code>ratioTiers[2]</code> = 3.16 is not read)</td></tr><tr><td>Utilization</td><td>avg active executors / peak &lt; 60% (info)</td><td>none</td></tr><tr><td>Autoscaling churn: short-lived executors</td><td>&gt; 30% of executors alive under 2 min (min 5 executors)</td><td>&gt; 60%</td></tr><tr><td>Job failure rate</td><td>≥ 30% (≥ 10% info)</td><td>≥ 50%</td></tr><tr><td>Idle cores</td><td>busy-core-time / (peak cores × wall-clock) idle &gt; 50% (warning)</td><td>none</td></tr><tr><td>Memory band</td><td>peak heap / allocated &gt; 95% too-small (warning); &lt; 70% over-provisioned (info)</td><td>none</td></tr><tr><td>Caching opportunity</td><td>same input relation (or join/union subtree) scanned by ≥ <code>minExecutions</code> = 2 SQL executions in one run</td><td>none (single tier, info)</td></tr><tr><td>Cache utilization: partial caching</td><td><code>numCachedPartitions / numPartitions &lt; 0.90</code> (info)</td><td><code>&lt; 0.50</code> (warning)</td></tr><tr><td>Cache utilization: disk spillover</td><td><code>diskSize / (memorySize + diskSize) &gt; 0.15</code> (info), <code>MEMORY_AND_DISK*</code> only</td><td><code>&gt; 0.40</code> (warning)</td></tr><tr><td>Cache utilization: storage unobserved</td><td>persisted RDDs, but no <code>SparkListenerBlockUpdated</code> for any <code>rdd_*</code> block and every RDD Info figure 0 (<code>spark.eventLog.logBlockUpdates.enabled</code> off on Spark 2.3+): a missing-evidence caveat, not a threshold</td><td>none (single tier, info)</td></tr><tr><td>Core locality</td><td>non-local task ratio ≥ <code>warnRatio</code> = 15% (min <code>minTasks</code> = 50 tasks)</td><td>≥ <code>critRatio</code> = 35%</td></tr><tr><td>Config: memory overhead</td><td><code>spark.executor.memoryOverhead</code> below max(<code>floorMB</code> = 384 MiB, <code>floorPct</code> = 10% of executor memory) (info)</td><td>none</td></tr></tbody></table><p>The RDD Info cache figures on stage events (<code>Number of Cached Partitions</code>, <code>Memory Size</code>, <code>Disk Size</code>) are always 0 since Spark 2.3; Spark 1.x fills them only on <code>StageCompleted</code>. Real cache evidence is <code>SparkListenerBlockUpdated</code>, written only with <code>spark.eventLog.logBlockUpdates.enabled=true</code> (<code>recordBlockUpdate</code> in <code>event-handlers.ts</code>); the corpus <code>cache-memory-only</code>/<code>cache-memory-and-disk</code> logs carry it. Count a block&#39;s sizes only where its storage level says it lives: a drop to disk still reports the dropped bytes as <code>Memory Size</code>.</p><p>Spill classification: ≥80% tasks with zero spill → <code>skew</code>; &lt;20% zero → <code>volume</code>; else <code>unclassified</code>. The classification badge is always shown in both compact and expanded spill widget states. This is independent of the magnitude tiers above: classification says <em>what kind</em> of spill, magnitude says <em>how much</em>.</p><h4 id="evidence-fields-stagefailed-retrywaste" tabindex="-1">Evidence fields (<code>stageFailed</code> / <code>retryWaste</code>) <a class="header-anchor" href="#evidence-fields-stagefailed-retrywaste" aria-label="Permalink to &quot;Evidence fields (`stageFailed` / `retryWaste`)&quot;">​</a></h4><p>Beyond a scalar <code>metric</code>/<code>value</code>, both entries&#39; <code>detect()</code> attach to the finding:</p><ul><li><code>numTasks</code>: <code>stage.taskCount</code> at detection time.</li><li><code>memoryBytesSpilled</code>: <code>stage.memoryBytesSpilled</code> at detection time.</li><li><code>stageFailed</code> only: <code>failedTaskDetails</code>: up to 20 <code>FailedTaskSample</code> records (<code>taskId</code>, <code>attemptNumber</code>, <code>host</code>, <code>executorId</code>, <code>reason</code>, <code>peakExecMem</code>, <code>memSpilled</code>, <code>shuffleWrite</code>) for tasks still marked failed when the stage was finalized (<code>finalizeStage</code>, <code>stage-quantiles.ts</code>).</li><li><code>retryWaste</code> only: <code>retriedTaskDetails</code>: up to 20 <code>FailedTaskSample</code> records for attempts discarded by the retry-dedup logic in <code>accumulateTask</code> (<code>event-handlers.ts</code>): captured at the moment they&#39;d otherwise be thrown away, since by finalize time only the winning attempt survives.</li></ul><p>Both sample arrays are capped at 20 entries, filled in first-encountered order (finalize order for <code>failedTaskDetails</code>, discard order for <code>retriedTaskDetails</code>), not spread across distinct hosts/executors: a stage with failures clustered on one bad host could fill the cap before a more informative failure elsewhere in the stage is ever sampled.</p><h4 id="evidence-fields-failures" tabindex="-1">Evidence fields (<code>failures</code>) <a class="header-anchor" href="#evidence-fields-failures" aria-label="Permalink to &quot;Evidence fields (`failures`)&quot;">​</a></h4><ul><li><code>dominantReason</code>: the most frequent end-reason tag (<code>ExceptionFailure</code>, <code>ExecutorLostFailure</code>, ...) among tasks still failed at finalize.</li><li><code>dominantError</code>: the error behind that tag, from its largest failure group: the exception class, or <code>&lt;tag&gt;: &lt;loss reason&gt;</code>; falls back to the tag. The recommendation names it and never embeds a message.</li><li><code>failureGroups</code>: up to 5 <code>TaskFailureGroup</code>s (<code>reason</code>, <code>className</code>, <code>message</code>, <code>lossReason</code>, <code>stackExcerpt</code>, <code>count</code>), most frequent first: one group per distinct tag, class, message and loss reason, one excerpt each.</li><li><code>otherFailedTasks</code>: failed tasks that no shown group covers.</li></ul><p><code>packages/core/src/task-failure.ts</code> reads the details from <code>Task End Reason</code> (<code>Class Name</code>, <code>Description</code>, <code>Full Stack Trace</code>, <code>Loss Reason</code>, FetchFailed <code>Message</code>, <code>Kill Reason</code>) and bounds them at ingest: a message or loss reason to its first line (for a Python traceback, its final error line), 300 characters; an excerpt to the header, 8 frames, a Python traceback&#39;s error line and the last <code>Caused by:</code> line, 2000 characters in all. While parsing, a stage keeps at most 50 distinct failures (<code>StageRecord.failureDetails</code>, freed at finalize); later ones count only toward the tag. Redaction (<code>redact.ts</code>) replaces every <code>failureGroups[].message</code> and strips message text from <code>stackExcerpt</code>.</p>',55)])])}const g=t(s,[["render",c]]);export{u as __pageData,g as default};
@@ -1 +0,0 @@
1
- import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const u=JSON.parse('{"title":"Detector contract","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/detector-contract.md","filePath":"contributor-guide/architecture/detector-contract.md"}'),s={name:"contributor-guide/architecture/detector-contract.md"};function c(r,e,n,i,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a("",55)])])}const g=t(s,[["render",c]]);export{u as __pageData,g as default};
@@ -1 +0,0 @@
1
- import{_ as o,o as t,c as a,a5 as d}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Drill-down","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/drill-down.md","filePath":"contributor-guide/architecture/drill-down.md"}'),n={name:"contributor-guide/architecture/drill-down.md"};function s(c,e,i,r,l,h){return t(),a("div",null,[...e[0]||(e[0]=[d('<h1 id="drill-down" tabindex="-1">Drill-down <a class="header-anchor" href="#drill-down" aria-label="Permalink to &quot;Drill-down&quot;">​</a></h1><p><code>StageDetailProvider</code> (<code>src/view/StageDetailContext.tsx</code>) exposes stage drill-down through React context. Any component calls <code>useStageDetail().openStage(stageId)</code> to open <code>StageDetailDialog.tsx</code>, a shadcn <code>Dialog</code> (base-ui), for that stage. The direct callers are StagePill, Timeline, StageTable and RunVerdict (a step&#39;s stage button). Every widget that embeds a <code>StagePill</code>, <code>StagePillGroup</code> or <code>StageHeader</code> reaches it indirectly.</p><p>The dialog is titled &quot;Stage N&quot;, with Spark&#39;s stage name (the code line that created the stage) as a labelled &quot;Code location&quot; description. Its body opens with one sentence placing the stage in the run (duration, share of the run&#39;s wall-clock, task count, how many finding types it carries), then lists each finding type at the stage (<code>locationKey</code>&#39;s rule, the one the verdict groups steps by: a <code>stageId</code> match, or a <code>stageIds</code> list naming only this stage) the way <code>RunVerdict</code> lists a step: tag and action label, the measurement (&quot;What&#39;s happening&quot;, split off by <code>recommendationParts</code>), &quot;What to try&quot;, the impact estimate, and a <strong>Show evidence</strong> button. Types follow the verdict&#39;s own order (<code>buildNextSteps</code>: potential savings first, failure findings first on a run whose jobs failed), with types outside the stage&#39;s own verdict step after them, worst band first. The grouping and order are computed once by <code>interpretRun</code> (<code>interpretStages</code> in <code>packages/core/src/run-interpretation.ts</code>, shipped as <code>stages[id].{findingIndexes,typeOrder}</code>); the dialog only renders them. Show evidence closes the dialog and calls the optional <code>onRoute</code> prop (<code>routeToVisible</code> from <code>Dashboard.tsx</code>, which clears any board filter that hides the target, as the verdict&#39;s own route does); <code>finalFocus</code> skips returning focus to the opener in that case, so the route&#39;s own focus on the evidence stands. Without <code>onRoute</code> the button is not shown.</p><h2 id="plan-dot-serialization" tabindex="-1">Plan DOT serialization <a class="header-anchor" href="#plan-dot-serialization" aria-label="Permalink to &quot;Plan DOT serialization&quot;">​</a></h2><p><code>packages/core/src/plan-dot.ts</code> (entry <code>planTreeToDot(planTree, { title })</code>) serializes a resolved <code>planTree</code> to a Graphviz DOT string. It is dependency-free string building. One pre-order walk (<code>walkPlanTree</code>, deduping shared subtrees) assigns each node a stable id and <code>label</code> (name, plus <code>detail</code> on a second line when it differs) and records its parent→child edge, since the parent&#39;s id always exists first. Edges are written after all node lines. The graph is laid out <code>rankdir=BT</code>, leaves at the bottom, matching Spark&#39;s own plan orientation.</p><p>It carries no metric annotation: pure structure. A null plan returns an empty string. There is no download/export UI for this output; <code>PlanView.tsx</code> calls it only to decide whether a stage&#39;s plan tree can render as a graph at all, and a non-empty result gates the &quot;View plan graph&quot; button.</p><h2 id="plan-graph-view" tabindex="-1">Plan graph view <a class="header-anchor" href="#plan-graph-view" aria-label="Permalink to &quot;Plan graph view&quot;">​</a></h2><p><code>buildPlanGraphModel(planTree, opts)</code> (<code>packages/core/src/plan-graph-model.ts</code>) flattens a resolved <code>planTree</code> into a <code>{ nodes, edges, segmentIndex, segmentCount, scope, segmentStageIds }</code> graph shape. <code>src/view/PlanGraphRoute.tsx</code> owns the view state and <code>src/view/plan-graph/PlanGraphCanvas.tsx</code> renders the model with <code>@xyflow/react</code> (React Flow, pan/zoom/viewport/MiniMap) and <code>@dagrejs/dagre</code> (node layout, <code>src/view/plan-graph/dagre-layout.ts</code>, <code>rankdir: &#39;RL&#39;</code>).</p><p>Every <code>PlanGraphEdge</code> points <code>source: parentId, target: id</code> (parent/consumer → child/producer), and dagre places an edge&#39;s source at the higher-rank end. So <code>RL</code>, rather than the more intuitive-looking <code>LR</code>, is what lands reads/scans (targets, computed first) on the left and the final write/root (source) on the right, matching the left-to-right reading order of the plan&#39;s data flow. Each node&#39;s React Flow <code>Handle</code>s follow the same horizontal routing: <code>type=&quot;target&quot;</code> on <code>Position.Right</code> (its parent sits to the right) and <code>type=&quot;source&quot;</code> on <code>Position.Left</code> (its children sit to the left), rather than the top/bottom anchors a vertical <code>TB</code>/<code>BT</code> layout would use.</p><p><code>PlanGraphNode.tsx</code> renders every node at a fixed <code>NODE_WIDTH × NODE_HEIGHT</code> (220×90, also what dagre lays the graph out around) with <code>truncate</code>/<code>title</code> on every text field. An operator with an unusually long label/detail/metric string can&#39;t inflate the box and overlap neighbors; it ellipsizes instead, full text on hover.</p><p>Every raw <code>Exchange</code>/<code>BroadcastExchange</code> plan node is split by <code>resolvePlanTree</code> (<code>packages/core/src/event-handlers.ts</code>) into paired write/read halves sharing a <code>sourceNodeId</code> once flattened into the graph. <code>ReusedExchange</code> is classified as an exchange for display purposes but is never split: it carries no <code>exchangeRole</code> and stays a single node. The write half is grouped with its children&#39;s producer component; the read half is grouped with its parent&#39;s consumer component. Both halves get their own entry in <code>buildDurationMap</code>&#39;s duration share. The read half has no metrics of its own and no same-segment descendants, so its share is 0 in exclusive and inclusive mode alike, and <code>PlanGraphNode.tsx</code> shows it as a 0% heat bar next to a &quot;paired: see write half&quot; note. The canvas leaves the share out of the read half&#39;s accessible label.</p><p>Default scope is <code>segment</code>: one Exchange-bounded slice of the plan, resolved via <code>computeSegments</code>/<code>zipSegmentsToStages</code> (<code>packages/core/src/plan-duration-attribution.ts</code>, shared with <code>attributeStageDurationToPlan</code>). <code>computeSegments</code> gives every connected component a stable pre-order identity plus separate parent/depth/traversal metadata. The numeric identity does not encode Exchange-ancestor depth: <code>zipSegmentsToStages</code> orders by the explicit depth metadata (deepest first, plan traversal order for ties), and the display-only fallback uses component-tree edge distance to find the nearest strictly paired component.</p><p>The view has an opt-in &quot;Expand to full plan&quot; toggle gated by a 300-node confirmation dialog (<code>ExpandConfirmDialog.tsx</code>). A segment-lookup failure that would otherwise render an unguarded full plan is routed through the same guardrail rather than bypassing it. That same failure can also resolve to a full-scope model <em>below</em> the guardrail threshold, rendering immediately with no dialog and no <code>requestedScope</code> change. Since it&#39;s a permanent property of that stage&#39;s plan (<code>buildPlanGraphModel</code> is deterministic per <code>(planTree, stageId, appModel)</code>), there is no segment view left for that stage to switch back to. So the toggle, still correctly labeled &quot;Back to segment view&quot; per <code>model?.scope</code>, renders <code>disabled</code> rather than silently no-opping on click. Only the explicit-expand path (<code>requestedScope === &#39;full&#39;</code>) leaves it enabled.</p><p>The four Plan Advisor finding types (<code>duplicatePlanSubtree</code>, <code>smallFiles</code>, and <code>broadcastSizing</code>&#39;s <code>overBroadcast</code>/<code>underBroadcast</code>, in <code>packages/core/src/detectors.ts</code>) set <code>Finding.planNodeIds</code>, an unambiguous pointer to the specific plan-tree node(s) each finding is about (every node of each repeated subtree occurrence for <code>duplicatePlanSubtree</code>, the flagged scan/write node for <code>smallFiles</code>, the <code>data size</code>-carrying nodes under both join inputs for <code>underBroadcast</code>, and the BroadcastExchange node for <code>overBroadcast</code>). <code>buildPlanGraphModel</code> indexes <code>findings</code> by <code>planNodeIds</code> and attaches each node&#39;s matches to its <code>PlanGraphNodeData.findings</code>, scoped to the current SQL execution (see below), and <code>PlanGraphNode.tsx</code> renders a corner badge from that per-node list. The badge follows the same dot+tag problem-flagging vocabulary as the rest of the dashboard: an <code>ImpactDot</code> plus the ALL-CAPS <code>typeTag</code> (<code>PLAN</code> for every current plan-node finding), colored by the worst band across the node&#39;s findings (critical &gt; warning &gt; info, via the local <code>worstFinding</code> helper), plus a count when the node carries more than one finding. Hovering or focusing the badge opens a tooltip listing each finding by its <code>findingActionLabel</code> (e.g. &quot;Dedupe repeated subtree&quot;, &quot;Compact small files&quot;), so the node discloses which findings hit it without leaving the graph. Every other finding type has no plan-node pointer and renders at the stage level only, via <code>Finding.stageId</code>/<code>Finding.stageIds</code>.</p><p><code>resolvePlanTree</code> prefixes every plan-node id with its owning SQL execution (<code>e&lt;executionId&gt;:n0</code>, <code>e&lt;executionId&gt;:n1</code>, ...), so ids are unique across executions. <code>buildPlanGraphModel</code> also filters <code>findings</code> to <code>finding.executionId === sqlExecutionId</code> (the execution the stage being graphed belongs to) before indexing by node id, as defense in depth: a hand-built or stale finding whose <code>planNodeIds</code> collide with this tree&#39;s ids still can&#39;t badge onto the wrong tree.</p><p>Two other per-node UI features render independently of findings:</p><ul><li>A traffic-light <strong>duration heat bar</strong> on each node (<code>plan-graph-heat.ts</code>&#39;s <code>heatBand</code>, consumed by <code>PlanGraphNode.tsx</code>) bands the node&#39;s duration share into critical/warning/info, reusing the impact-band color tokens (see the Plan Violet exception noted in <code>DESIGN.md</code>).</li><li>A <strong>duration-attribution mode toggle</strong> (<code>PlanGraphDurationModeControl.tsx</code>) switches every node&#39;s displayed share between &quot;Node only&quot; (exclusive) and &quot;Node + descendants&quot; (inclusive), threaded into the model-build/cache key as <code>durationMode</code>.</li></ul><p>One more identity subtlety: a split <code>Exchange</code>/<code>BroadcastExchange</code> pair (the write/read halves <code>resolvePlanTree</code> synthesizes, see above) counts as <strong>one</strong> node for <code>duplicatePlanSubtree</code>&#39;s subtree-size and occurrence counting. <code>computePlanShapes</code> in <code>detectors.ts</code> walks straight through the read half to the write half&#39;s real children, so every real Exchange in a matched subtree is counted once instead of twice.</p><p>The segment-level group box (<code>PlanGraphSegmentGroupNode.tsx</code>) always renders, even in the default single-stage view where it&#39;s the only box on screen. It is headered with its stage id (via <code>segmentStageIds</code>) and a duration chip, or an em-dash placeholder when the segment has no attributed duration. It also carries one finding chip (<code>PlanGraphFindingChip</code>) per finding on its zipped stage, in both the default and the expanded view. Each chip is a <code>TagBadge</code> (the ALL-CAPS tag) followed by the finding&#39;s compact magnitude and recoverable-time detail from <code>formatFindingChipDetail</code> (e.g. &quot;SPILL 4.2 GB · ~38s&quot;: the finding&#39;s own <code>value</code>/<code>metric</code> and its <code>impactEstimate.wallClock</code>), or the bare tag when the finding carries neither.</p><p>The expanded full-plan view draws two nested layers of background group boxes (Dagre <code>compound: true</code> for spacing, <code>computeGroupBounds</code> for both): the same inner segment box described above, plus an outer solid tinted container per distinct stage id (<code>PlanGraphStageGroupNode.tsx</code>, layered under the inner segment box, which is in turn under the plan nodes; all three sit above the edge layer so a routed edge can&#39;t paint over a box&#39;s finding chips) merging every segment zipped to that stage, so a stage split across several Exchange-bounded segments still reads as one unit of work. In this expanded view the segment box still carries its stage&#39;s finding chips inline in its header row. The outer stage box paints its own copy only as a fallback, for a stage no segment box is zipped to (for example, the focal stage when it lost the segment/stage pairing), so every finding appears exactly once. Chips match a stage through <code>Finding.stageId</code> or <code>Finding.stageIds</code>; the default segment scope uses the same matching for its displayed stage. Clicking an outer stage box (&quot;Focus stage N&quot;) switches the route to that stage&#39;s segment view (<code>handleSelectStage</code> in <code>PlanGraphRoute.tsx</code>), with no reopen.</p><p>The outer layer&#39;s own corner tag is positioned opposite the segment box&#39;s top-left header, because a stage with just one segment (the common case) would otherwise show the same &quot;Stage N&quot; header twice, stacked. <code>STAGE_GROUP_PADDING_Y</code> (64, no reserved header height) is sized to strictly exceed the segment layer&#39;s own reserved offset (<code>GROUP_PADDING + GROUP_HEADER_HEIGHT</code> = 56 at the top), while <code>STAGE_GROUP_PADDING_X</code> (32) is kept close to <code>GROUP_PADDING</code>&#39;s own 24px floor so the outer box doesn&#39;t visibly balloon sideways. Both still strictly contain the outer box around its nested segment box(es).</p><p>Every global control lives on one <strong>vertical control rail</strong> down the left edge (<code>PlanGraphControlRail.tsx</code>), grouped View / Navigate / Display, so nothing floats in its own corner. View is zoom in/out and fit (in place of React Flow&#39;s <code>Controls</code>); Navigate is &quot;Next worst duration&quot; and &quot;Next problem&quot; (cycling to the worst-share node and the worst-finding stage, via <code>setCenter</code>); Display is the node-filter/duration Settings popover (<code>PlanGraphSettingsControl</code> with its <code>iconOnly</code> rail variant), plus the legend and minimap toggles. The rail renders inside <code>ReactFlowProvider</code> alongside <code>&lt;ReactFlow&gt;</code>, so its <code>useReactFlow</code> zoom/center calls drive the same instance.</p><p>Clicking a node opens the <strong>detail inspector</strong> (<code>PlanGraphNodeDetail.tsx</code>), a right-docked panel (not a floating card) that reflows the graph rather than covering it. Each node box is a fixed size and truncates every field to one line, showing a single <code>primaryMetric</code>; the inspector is where the whole operator is legible: category and segment, duration share, the node&#39;s findings (dot + tag + <code>findingActionLabel</code>), the operator&#39;s <strong>full metric set</strong> (<code>PlanGraphNodeData.metrics</code>, every metric formatted through <code>formatPlanMetricValue</code>), and the <strong>complete plan text</strong> (<code>PlanGraphNodeData.detailText</code>, the untruncated <code>PlanNode.detail</code>). For a split Exchange half the inspector adds an <strong>Exchange section</strong>: which half is in view, the shuffle volume across the boundary (<code>PlanGraphNodeData.exchangeShuffleBytes</code>, the same producing-stage bytes the pairing edge is weighted by, mirrored onto both halves so it shows whichever half is open, or &quot;Broadcast, no shuffle&quot;), and a <strong>jump to the paired half</strong> (<code>PlanGraphNodeData.pairedNodeId</code>). Because the two halves always sit in different segments, the jump selects the partner directly when it is already on screen (full plan) and otherwise expands to the full plan first, then selects it (<code>handleJumpToPaired</code> in <code>PlanGraphRoute.tsx</code>, carried past the selection-reset effect by a pending-jump state); either way the canvas recenters on it via a <code>centerRequest</code> token. The selected node id lives in <code>PlanGraphRoute.tsx</code>, not the canvas, so the route&#39;s one Escape handler closes the inspector first and the whole route only on a second press. Nodes are <code>draggable: false</code> (a read-only, auto-laid-out graph); they click to inspect but don&#39;t move.</p><p>The remaining legibility aids sit on the canvas itself:</p><ul><li>The <strong>MiniMap</strong> (bottom-right, toggled from the rail) colors each node by the worst finding band on it (<code>planGraphMiniMapNodeColor</code>, <code>plan-graph-minimap.ts</code>), so the overview shows where the problems are; a node with no finding keeps the neutral plan color and the large group boxes recede into a muted fill.</li><li>A <strong>legend</strong> panel (<code>PlanGraphLegend.tsx</code>, open by default, toggled from the rail) keys the operator icons, the heat-bar colors, the shuffle-weighted edge thickness, and the segment-vs-stage box layers.</li><li>When the category filter hides every operator in view, a <strong>status hint</strong> (top-center) names the count hidden and points at Settings, instead of leaving only empty group boxes on screen.</li></ul><p>The view is reached from a &quot;View plan graph&quot; button in <code>PlanView.tsx</code>&#39;s toolbar (gated by the same <code>if (dot)</code> check described in &quot;Plan DOT serialization&quot; above), which opens the stage&#39;s segment, and from the topbar&#39;s graph-view control, which opens the full plan (<code>initialScope: &#39;full&#39;</code>, still behind the 300-node guardrail). Both set a <code>planGraph: { active, stageId, initialScope }</code> Zustand slice (<code>openPlanGraph</code>/<code>closePlanGraph</code>, <code>src/store/store.ts</code>) driving a top-level <code>AppRoutes</code> branch in <code>src/App.tsx</code>, modeled on the <code>comparison</code>/<code>RunComparisonRoute</code> full-takeover pattern.</p><p><code>buildPlanGraphModel</code>&#39;s output is memoized per <code>(activeFileId, stageId, scope, durationMode)</code> in <code>PlanGraphRoute.tsx</code>, since <code>stageId</code> alone isn&#39;t unique across loaded runs and <code>applySnapshot</code> mutates <code>appModel</code> in place rather than replacing it (see <a href="./state-and-history.html#state-model">State model</a>). The memo cache is a module-level <code>Map</code>, so it survives across route open/close: re-opening the same stage in the same run reuses the cached model instead of rebuilding it. It must still be evicted on a fresh parse or reload. <code>resetModel()</code> (<code>store.ts</code>) bumps a <code>modelResetCount</code> counter for exactly this purpose, and <code>PlanGraphRoute.tsx</code> subscribes to it to clear the cache. A counter rather than a direct call, since <code>store.ts</code> has no view-layer imports anywhere else and importing a <code>.tsx</code> module there would invert that dependency direction.</p><h2 id="disclosure-hierarchy" tabindex="-1">Disclosure hierarchy <a class="header-anchor" href="#disclosure-hierarchy" aria-label="Permalink to &quot;Disclosure hierarchy&quot;">​</a></h2><p>The Summary/Context/Details 3-tier framing (collapsed lead metric → expanded widget body → per-stage <code>StageDetailDialog</code>) does not apply uniformly across the 28 registry widgets (<code>src/view/detector-registry.tsx</code>). 18 have a stage-anchored Details tier reachable via <code>StagePill</code>/<code>StagePillGroup</code>: Skew, StageShape, TinyTask, ShuffleIO, PartitionSizing, Spill, GcPressure, StageFailed, TaskFailures, RetryWaste, SlowHost, StageSlowness, Straggler, SpeculationWaste, and DuplicatePlanSubtree, SmallFiles, UnderBroadcast and OverBroadcast (sql-scope, so they list every stage in <code>stageIds</code> as a <code>StagePillGroup</code> rather than one <code>StagePill</code>). The other 10 are app- or config-scope with no stage to drill into, by design, so they stop at Summary/Context: MemoryUtilization, ExecutorUtilization (<code>utilization</code> is an app-wide average, no stage), JobFailures, ConfigAudit, CacheUtilization, CoreUsageArea, AutoscalingChurn, CachingOpportunity (<code>scope: &#39;app&#39;</code>, <code>stageId: null</code> on both its finding constructions, so it has no stage to anchor to despite reading like a per-stage widget), IncompleteRun, and ColdStart (unlike SlowHost, StageSlowness, Straggler and SpeculationWaste, app-scoped with no <code>stageId</code>).</p><h2 id="reference-panel" tabindex="-1">Reference panel <a class="header-anchor" href="#reference-panel" aria-label="Permalink to &quot;Reference panel&quot;">​</a></h2><p><code>DocsLink</code> (<code>src/view/DocsContext.tsx</code>), finding tag pills (<code>TagBadge</code>) and a few guide links call <code>useDocs().open(anchor)</code> or <code>openSite(path)</code> to open a docked, resizable docs panel (<code>src/view/DocsSheet.tsx</code>, mounted once in <code>src/App.tsx</code> inside <code>DocsProvider</code>). The topbar&#39;s Docs button is a plain link that opens the docs site in a new tab. The panel iframes a docs-site (VitePress) page, built from the tuning reference under <code>packages/core/src/docs-content/{chapters,tuning,diagrams}</code> (generated at build and test time by <code>scripts/fetch-tuning-docs.mjs</code>, gitignored, from the <code>shuffle-works/spark-tuning-reference</code> commit pinned in the committed <code>docs-content/upstream.json</code>; <code>npm run docs:bump</code> moves the pin) and published as static HTML at <code>docs/tuning-reference/&lt;page&gt;.html</code> (<code>docs-config.ts</code>&#39;s <code>docsUrl()</code> resolves an anchor to that path plus a <code>#&lt;anchor&gt;</code> fragment). <code>open</code>/<code>openSite</code> set React state (<code>isOpen</code>, <code>target</code>). The panel is a non-modal base-ui <code>Dialog</code> (<code>modal={false}</code>, <code>disablePointerDismissal</code>) laid out with <code>react-resizable-panels</code>. The dashboard stays interactive and reflows beside it through the <code>--docs-inset</code> CSS variable. Only the close button or Escape dismisses it, and base-ui owns focus-in, focus-restore and Escape. Key events inside the iframe never reach the app&#39;s document, so <code>DocsSheet</code>&#39;s <code>listenForEscapeInFrame</code> also listens in a same-origin frame&#39;s own document and closes on Escape unless the docs&#39; search popup is open (a cross-origin frame keeps only the close button). Exported dashboards have no docs, so they never open this panel. There is a single <code>DocsTarget</code> shape (<code>{ kind: &#39;site&#39;, source: &#39;reference&#39; | &#39;guide&#39;, path }</code>, set by <code>open(anchor)</code> or <code>openSite(path)</code>; <code>source</code> only picks the panel title), so <code>DocsSheet</code> always drives the iframe the same way, reassigning <code>src</code> on any path or theme change.</p><p>Most anchors the app links to are the page of the same name; a handful are in-page fragments on another page instead (config-audit sub-findings and the metric glossary live on the <code>config</code>/<code>metrics</code> pages; bottleneck sub-anchors such as <code>bottleneck-stage-shape</code> live on the page of the bottleneck that owns them, and two, <code>bottleneck-autoscaling-churn</code> and <code>bottleneck-cache-utilization</code>, on the <code>cluster-config</code>/<code>memory-model</code> chapters): <code>docs-config.ts</code>&#39;s <code>pageForAnchor()</code> is the one place that resolves an anchor to its owning page. <code>npm run docs:build</code> (run automatically by <code>npm run build</code>) generates <code>packages/core/src/docs-content/</code> from the pin if needed (<code>scripts/fetch-tuning-docs.mjs</code>), reshapes it into <code>docs-site/tuning-reference/*.md</code> (<code>scripts/build-tuning-reference.mjs</code>), then VitePress renders it into <code>docs-site/.vitepress/dist</code>, and the <code>copyDocsSite</code> plugin (<code>vite-plugins/copy-docs-site.ts</code>, registered in <code>vite.config.ts</code>) copies that output to <code>dist/docs</code>; Vite&#39;s relative asset base keeps the app and docs usable when <code>dist/</code> is deployed under a URL subpath. <code>tests/doc-anchor-coverage.test.js</code> intersects <code>packages/core/src/docs-content/chapters/nav-index.json</code> against every detector&#39;s <code>docAnchor</code> and warns (never fails) on dead links (detector points at an anchor the nav index doesn&#39;t have) or orphaned Detector Catalog anchors (no detector points at them); see <code>scripts/doc-anchor-coverage.js</code>. The landing page (<code>docs-site/tuning-reference/index.md</code>, the symptom-picker entry page) is hand-authored, committed markdown, unlike the generated pages next to it.</p><p>A docs-site page has no channel back to this app: it&#39;s a plain static page with no <code>postMessage</code> listener. <code>DocsSheet.tsx</code> reassigns the iframe&#39;s <code>src</code> outright on any change to the resolved path or the theme, forcing a full reload. Since re-assigning the exact same <code>src</code> string wouldn&#39;t make the browser reload it, a <code>t=&lt;theme&gt;</code> marker is threaded into the query string ahead of the <code>#anchor</code> hash purely to change the string and force a real reload; the page never reads that param itself: it reads its light/dark preference once, from the <code>vitepress-theme-appearance</code> localStorage key <code>ThemeProvider</code> keeps current, the moment it boots.</p>',33)])])}const u=o(n,[["render",s]]);export{g as __pageData,u as default};
@@ -1 +0,0 @@
1
- import{_ as o,o as t,c as a,a5 as d}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Drill-down","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/drill-down.md","filePath":"contributor-guide/architecture/drill-down.md"}'),n={name:"contributor-guide/architecture/drill-down.md"};function s(c,e,i,r,l,h){return t(),a("div",null,[...e[0]||(e[0]=[d("",33)])])}const u=o(n,[["render",s]]);export{g as __pageData,u as default};
@@ -1 +0,0 @@
1
- import{_ as t,o,c as a,a5 as d}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Impact estimation","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation.md","filePath":"contributor-guide/architecture/impact-estimation.md"}'),s={name:"contributor-guide/architecture/impact-estimation.md"};function c(r,e,i,n,l,h){return o(),a("div",null,[...e[0]||(e[0]=[d('<h1 id="impact-estimation" tabindex="-1">Impact estimation <a class="header-anchor" href="#impact-estimation" aria-label="Permalink to &quot;Impact estimation&quot;">​</a></h1><p>Every finding covered by this section carries an optional <code>impactEstimate: {basis, wallClock, estimateMethod, rawWaste?, coreTimeMs?}</code> (<code>packages/core/src/types.ts</code>), attached by its <code>DETECTORS</code> entry&#39;s <code>estimate()</code> (<code>packages/core/src/detectors.ts</code>), which <code>estimateImpact()</code> (<code>packages/core/src/impact-estimator.ts</code>) runs as a post-pass once detection and suppression finish (<code>packages/core/src/analyzer.ts</code>). The waste models those methods compose (assumed throughputs, per-stage measurements, the occupancy clip wrappers) live in <code>packages/core/src/impact-model.ts</code>, and <code>analyze()</code> builds the one <code>EstimateCtx</code> (<code>stages</code>, <code>occupancy</code>, <code>totalCores</code>) every <code>estimate()</code> and every detector&#39;s runtime floor reads. <code>estimateMethod</code> (&#39;measured&#39; | &#39;modeled&#39; | &#39;none&#39;) is a distinct axis from the per-finding <code>confidence</code> field (<a href="./board-widgets.html#confidence-metadata">Confidence metadata</a>): <code>confidence</code> says how much to trust the finding itself, <code>estimateMethod</code> says how its impact number was derived. <code>&#39;none&#39;</code> marks a purely informational finding with no waste model at all (<code>configAudit</code>, <code>stageFailed</code>, <code>failures</code>, <code>incompleteRun</code>, <code>slowHost</code>&#39;s byte-dimension multiDim shapes, <code>gc</code>&#39;s low-GC direction, <code>cacheUtilization</code>&#39;s <code>storageUnobserved</code> caveat, <code>duplicatePlanSubtree</code> when its repeats differ or map to no stage): it&#39;s distinct from <code>&#39;measured&#39;</code>/<code>&#39;modeled&#39;</code>, which both attach a real (if approximate) formula. <code>basis</code> is one of:</p><ul><li><code>&#39;serial&#39;</code>: the tied stage ran (effectively) alone; <code>wallClock</code> is a near-point estimate, <code>low === high</code>.</li><li><code>&#39;contended&#39;</code>: the tied stage shared wall-clock time with others; <code>wallClock</code> is an honest range, <code>high</code> optimistic (assumes the fix could still fully land), <code>low</code> the guaranteed floor.</li><li><code>&#39;resourceOnly&#39;</code>: no wall-clock claim is defensible (not stage-tied by nature, or the stage was excluded from the occupancy sweep), <code>wallClock: null</code>, but the formula&#39;s real signal survives in <code>rawWaste</code>.</li><li><code>&#39;informational&#39;</code>: no quantifiable magnitude at all, <code>wallClock: null</code>, no <code>rawWaste</code>.</li></ul><p><code>rawWaste</code> is not exclusive to <code>resourceOnly</code>/<code>informational</code> findings: every <code>serial</code>/ <code>contended</code> finding carries it too (the only exceptions are <code>coldStart</code>, whose <code>wallClock</code> figure is already unclipped, and <code>estimateMethod: &#39;none&#39;</code> findings, which have no formula at all), holding the formula&#39;s pre-clip magnitude in the formula&#39;s own natural unit (ms, bytes or core-ms). <code>wallClock</code> is what the occupancy model says is recoverable, which clips against the stage&#39;s own physical floor; <code>rawWaste</code> is what the stage really wasted either way. The two answer different questions.</p><p><code>coreTimeMs</code> is the busy core time the fix removes, the executor task time, as <code>{low, high}</code> core-milliseconds with <code>low === high</code>, or <code>null</code> when the detector measures none (never 0 for unknown). <code>estimateImpact()</code> sets it for every estimate through <code>coreTimeFor()</code> in <code>packages/core/src/impact-model.ts</code>, and only from a measured figure: skew and straggler&#39;s removed task time (<code>tailClaimImpact()</code> sets it from the claim&#39;s <code>removedCoreWorkMs</code>), or the raw figure of a type in <code>MEASURED_CORE_TIME_FIGURE</code>: gc&#39;s <code>jvmGCTime</code> (<code>coreMs</code>) and the cross-task executor-time <code>ms</code> sums of <code>retryWaste</code> and <code>speculationWaste</code>. These are set even when the log has no executor cores. A wall-clock claim is never converted to core time, so every finding with only a wall-clock claim gets <code>null</code>. A modeled figure, one that rests on an assumed constant, gets <code>null</code> too: coreLocality&#39;s <code>coreMs</code> (non-local tasks × <code>NETWORK_FETCH_PENALTY_MS</code>) and the <code>coreHours</code> of <code>autoscalingChurn</code> and <code>jobFailureRate</code>. <code>estimateMethod</code> does not decide this, because it describes the wall-clock figure: gc&#39;s is <code>modeled</code> while its <code>jvmGCTime</code> is read from the log. <code>executorCpuTime</code> is never read because it leaves out Python worker CPU.</p><p>Every surface reads <code>coreTimeMs</code> and <code>remediation</code> from the one <code>analyze()</code> result: the dashboard stores it, the HTML export ships it in its <code>catalog</code>, and the CLI report and MCP tools put it in their finding rows through <code>buildEvidenceReport()</code>. <code>surface-parity.test.js</code> compares the four on the public corpus logs.</p><p><code>coreTimeMs</code> is busy time only. A <code>coreMs</code>/<code>coreHours</code> raw figure that counts allocated capacity no task ran on carries <code>rawWaste.idle: true</code>: <code>utilization</code> (<code>coreHours</code>) and <code>stageShape</code>&#39;s <code>lowParallelism</code> and <code>taskStageSkew</code> (<code>coreMs</code>). <code>coreTimeFor()</code> gives those <code>null</code>, and <code>rawWasteMeaning()</code> in <code>packages/core/src/impact-format.ts</code> labels them &quot;of idle core capacity&quot; instead of &quot;of core time&quot; on every surface. <code>memoryUtilization</code>&#39;s <code>idleCores</code> counts <code>mbSeconds</code>, which has no core time either. A stage&#39;s slow tail is also counted once: <code>skew</code> and <code>straggler</code> both measure the task time removed from it, so on one stage <code>skew</code> carries it and <code>straggler</code> has <code>null</code> (<code>countTailCoreTimeOnce()</code> in <code>packages/core/src/impact-estimator.ts</code>).</p><h2 id="occupancy-weighted-attribution" tabindex="-1">Occupancy-weighted attribution <a class="header-anchor" href="#occupancy-weighted-attribution" aria-label="Permalink to &quot;Occupancy-weighted attribution&quot;">​</a></h2><p><code>packages/core/src/occupancy.ts</code> sweeps every stage&#39;s observed <code>[submittedAt, completedAt)</code> window and splits each instant&#39;s wall-clock among concurrently-active stages proportional to <code>coreWeight(S) = stage.executorRunTime / stageDurationMs</code> (an average-concurrency proxy, held constant across the stage&#39;s whole window: this codebase has no per-task timestamps outside the parser worker to do better). Summing a stage&#39;s share across its own window gives its <code>occupancy(S)</code>; <code>gate(S) = occupancy(S) / duration(S) ∈ [0, 1]</code> is the single number the estimate reads. 1.0 means the stage ran completely alone; 0 means the stage had zero <code>executorRunTime</code> while overlapping other, positive-weight stages, so it got no share of the shared window. When every stage active over an interval has zero weight (no <code>executorRunTime</code>), that interval is split equally among them instead, so a stage that ran alone still gets <code>gate</code> 1. Stages with <code>duration(S) &lt;= 0</code> (Spark-skipped stages, or a malformed <code>submittedAt === completedAt</code>) are excluded from the sweep entirely.</p><p><code>efficiency-model.ts</code> reports <code>floorZeroSkewMs</code> (total task time / peak concurrent cores) as its theoretical floor.</p><p><code>ceiling(S) = max(stage.taskDurationMax, stage.executorRunTime / totalCores)</code> is a physical floor on a stage&#39;s own duration: bounded below by its single longest task (unsplittable no matter how much parallelism exists) or by its core-work spread across every core in the cluster, whichever is larger. Every waste formula&#39;s raw claim is clipped against it before gate-weighting: <code>wasteMs_clipped(S) = min(wasteMs_claimed, max(0, duration(S) - ceiling(S)))</code>, so a finding can never claim to save more than the portion of the stage&#39;s observed duration that sits above its own unbeatable floor.</p><p><code>skew</code> and <code>straggler</code> are the exception (<code>estimateSingleStage</code>&#39;s <code>shortensLongestTask</code> option, passed by <code>detectors.ts</code>&#39;s <code>tailClaimImpact</code>, which both their <code>estimate()</code> and their <code>detect()</code> runtime-floor gate call on the same tail claim, so firing and display agree; <code>stageSlowness</code>&#39;s more-partitions estimate passes it too, since splitting partitions splits the longest task). Their claim shortens the stage&#39;s longest task itself, so <code>taskDurationMax</code> can&#39;t be their floor: clipping against it would cap a stage gated by one straggler at <code>duration(S) − taskDurationMax</code>, about zero, exactly when the fix recovers the most. Their floor is instead the longest task the fix leaves plus the core work the fix leaves, <code>max(taskDurationMax − wasteMs_claimed, longestTaskAfterFixMs, (stage.executorRunTime − removed) / totalCores)</code>, where <code>removed</code> (<code>tailRemovedWorkMs</code>) is the larger of the finding&#39;s single-task delta (see below) and <code>stragglerExcessMs</code>, and <code>longestTaskAfterFixMs</code> is the longest task the fix leaves for <code>skew</code> and <code>straggler</code> (see below). Counting the stragglers&#39; own run time as work the stage can&#39;t shed would floor a stage whose tail is most of its core time near its observed duration.</p><p>The claim itself is <code>tailRecoveryMs</code> (<code>occupancy.ts</code>), which returns the stage&#39;s <code>tailReplayRecoveryMs</code>: <code>finalizeStage</code> replays the stage&#39;s own tasks with list scheduling (<code>computeTailReplayRecoveryMs</code> in <code>stage-quantiles.ts</code>: slots = <code>peakConcurrentTasks</code>, at least 1 and at most the task count; tasks in launch order, equal launch times in task-array order, each on the slot that frees first) once with the real durations and once with every task over 4× P50 capped at P50, and takes the difference. It runs only on stages with a task over 4× P50 (every other stage recovers 0) and costs a launch-order index and a slot heap per stage: 5-9 ms over the 236 such stages (23,641 tasks) of the largest real log, whose parse takes 5.5-6.9 s. <code>dev/eval-tail-replay.mjs</code> keeps an independent copy of the replay as its ground truth, and the two agree on every tail stage of the real logs and the corpus.</p><p>A stage without the field (one built by hand, as in detector tests) falls back to a stage-level estimate: the larger of the single-task delta above and <code>stragglerExcessMs / peakConcurrentTasks</code>, the summed excess over P50 of every task slower than 4× P50, spread over the most tasks the stage ever ran at once. That estimate assumes the tail either gates the stage through one task or spreads evenly over the slots; the replay knows when each slow task launched. Stragglers clustered at the end of a stage recover more than the even spread (one real stage: 12 of 200 tasks over 4× P50 on 29 slots, 89.0s estimated, 127.3s replayed), a long task launched early overlaps the rest of the stage and recovers less than its excess (8.4s estimated, 3.0s replayed), and a speculation-driven stage with no task over 4× P50 recovers nothing (5.8s estimated on two real stages).</p><p>The single-task deltas set the removed work above and the fallback estimate. <code>straggler</code>&#39;s runs from <code>taskDurationMax</code> down to the longest task its fix leaves (<code>stragglerFixLongestTaskMs</code>), not down to P50: the fix brings every task over 4× P50 to the median, so the stage still waits on its longest task at or under that (<code>longestNonStragglerMs</code>, from <code>finalizeStage</code>), and that task is also passed as a floor (<code>longestTaskAfterFixMs</code>). A speculation-driven finding with no task over 4× P50 keeps the P50 delta. <code>skew</code> keeps its P50 delta, since repartitioning may even out tasks under 4× P50 too, but takes the same <code>longestTaskAfterFixMs</code> floor: without it a 100 s stage whose next-longest task ran 39 s would have skew claiming 90 s where straggler claims 61 s.</p><p><code>analyzer.ts</code> feeds this <code>totalCores</code> from <code>packages/core/src/core-count.ts</code>&#39;s <code>computePeakConcurrentCores(app, executorsAdded, executorsRemoved)</code> (the same peak concurrent capacity <code>efficiency-model.ts</code>, <code>wasted-core-hours.ts</code> and the <code>utilization</code>/<code>memoryUtilization</code> detectors use), not <code>computeTotalCores</code>, which only <code>scaling-sim.ts</code> uses. <code>computeTotalCores</code> sums every <code>ExecutorAdded</code> event&#39;s cores regardless of overlap, so under dynamic allocation or executor replacement it can far exceed the cores ever actually concurrent, which understates <code>ceiling(S)</code> and lets churn inflate a finding&#39;s claimed wall-clock. <code>computePeakConcurrentCores</code> instead sweeps add/remove events by timestamp and tracks the running total&#39;s peak, so a churned-through executor&#39;s cores are never double-counted against its replacement&#39;s. Same-timestamp events tie-break by delta ascending, so a removal applies before a same-instant replacement&#39;s addition (otherwise a same-instant swap would momentarily double-count both as concurrent). If every <code>executorsAdded</code> entry lacks <code>totalCores</code> the cores sweep peaks at zero and tells us nothing; the function then falls back to sweeping peak <em>executor count</em> instead (still concurrency-aware, just cores-blind) and multiplies by the configured per-executor core count, rather than falling back to <code>executorsAdded.length × cores</code>, which overcounts under churn.</p><p>Per-finding estimate, using <code>wasteMs_clipped(S)</code>:</p><ul><li><code>gate(S) &gt;= 0.999</code>: <code>basis: &#39;serial&#39;</code>, <code>low = high = wasteMs_clipped(S)</code>.</li><li><code>gate(S) &lt; 0.999</code>: <code>basis: &#39;contended&#39;</code>, <code>high = wasteMs_clipped(S)</code>, <code>low = wasteMs_clipped(S) * gate(S)</code>.</li></ul><p>A finding spanning multiple stages (<code>stageIds</code>, plural) sums each stage&#39;s own estimate and caps the joint total at the union of just that finding&#39;s own stage windows (via <code>mergeIntervals</code>, <code>packages/core/src/intervals.ts</code>): <code>high = min(Σ high_i, unionMs(stageIds))</code>, <code>low = min(Σ low_i, unionMs(stageIds))</code>. This is what prevents overclaiming when two or more of a finding&#39;s stages overlap in wall-clock time: a plain sum-and-cap. The union cap can force <code>low === high</code> numerically even when the constituent stages were individually contended (e.g. two fully-overlapping stages each at <code>gate</code> 0.5), so <code>basis</code> isn&#39;t derived from that numeric equality: a multi-stage finding gets <code>basis: &#39;serial&#39;</code> only when every one of its per-stage estimates was itself <code>&#39;serial&#39;</code>; otherwise <code>&#39;contended&#39;</code>.</p><p>Regression guard: a finding whose stage (or, for a multi-stage finding, every one of its stages) ran alone (<code>gate &gt;= 0.95</code>, deliberately looser than the <code>0.999</code> &quot;serial&quot; cutoff above: this guard exists to catch egregious false zeros, not to gate which <code>basis</code> a finding gets) with a real underlying magnitude (<code>rawWaste.value &gt; 0</code>) must never report <code>wallClock.high === 0</code> or <code>basis: &#39;informational&#39;/&#39;resourceOnly&#39;</code>. Covered by <code>packages/core/test/impact-estimator-real-log.test.js</code> (run with <code>npm run test:core</code>; skipped unless the <code>private-log-01.zstd</code> fixture is in <code>examples/</code>); relies on <code>rawWaste</code> being attached to every serial/contended-capable formula (see above), so it&#39;s blind to <code>coldStart</code>, the purely informational (<code>estimateMethod: &#39;none&#39;</code>) finding types, <code>stageShape</code> (resourceOnly by design) and <code>shuffle</code> findings whose stage measured zero fetch wait (a measured zero, not a clip artifact).</p><p>Real-log spot-check (<code>private-log-01.zstd</code>): median <code>gate</code> across stages was <code>≈0.34</code> (0.3428060791718594 exactly); <code>collectRun</code> plus the occupancy sweep together took <code>≈6,589</code>ms on the largest fixture measured (<code>private-log-03.zstd</code>, <code>1169</code> stages): parse-dominated, the sweep alone was not isolated by this measurement, but not a magnitude that suggests a regression either.</p><h2 id="cross-finding-rollup-computestageunionms" tabindex="-1">Cross-finding rollup: <code>computeStageUnionMs</code> <a class="header-anchor" href="#cross-finding-rollup-computestageunionms" aria-label="Permalink to &quot;Cross-finding rollup: `computeStageUnionMs`&quot;">​</a></h2><p>The Findings tab&#39;s recommendation rollup (<code>FixTheseFirst.tsx</code>, which renders <code>interpretRun</code>&#39;s <code>rankedRollup</code> over <code>buildRecommendationRollup</code>, <code>packages/core/src/recommendation-rollup.ts</code>) groups the filtered catalog by detector <code>type</code>, then needs its own cap for a group of several findings of that type, not just one finding&#39;s own <code>stageIds</code>. Summing each finding&#39;s already-clipped <code>wallClock.high</code> naively double-counts any stage two of those findings both touch. <code>computeStageUnionMs(stageIds, stages)</code> covers this: collect every stage touched by any finding in the group, merge their <code>[submittedAt, completedAt)</code> intervals, and sum the merged intervals&#39; durations, so the group&#39;s wall-clock union holds regardless of how many findings&#39; <code>stageIds</code> overlap. Stages missing either bound are skipped rather than defaulted to <code>0</code> (the same filter <code>computeWallClock</code> applies), so a truncated log (the case <code>incompleteRun</code> flags) can&#39;t contribute a negative interval and a negative recoverable-time figure.</p><p>It reuses the same <code>mergeIntervals</code> primitive (<code>packages/core/src/intervals.ts</code>) that backs <code>packages/core/src/occupancy.ts</code>&#39;s per-finding <code>estimateMultiStage</code>/its internal <code>unionMs</code> sum, but is not an extension of that function: <code>estimateMultiStage</code> caps one finding&#39;s own multi-stage claim during the impact-estimation pass, before a <code>Finding</code> object even exists; <code>computeStageUnionMs</code> runs later, over the finished findings (in <code>interpretRun</code> and the evidence report), capping a naive sum <em>across</em> several already-estimated findings that happen to share a detector <code>type</code>. <code>buildTimeGroup</code> (same module) takes the smaller of the naive per-finding sum and this union figure as the group&#39;s <code>recoverableMsHigh</code>, falling back to the naive sum untouched when the group&#39;s findings carry no stage IDs at all (nothing to union against).</p><p>Within one <code>type</code>, <code>buildRecommendationRollup</code> splits findings into up to three kinds of group; across the whole rollup every <code>time</code> group sorts before every <code>resource</code> group, which sorts before every <code>count</code> group. <code>time</code> covers findings with a real <code>impactEstimate.wallClock</code> (<code>buildTimeGroup</code>, the union-capped figure above). <code>resource</code> covers findings with no <code>wallClock</code> but a <code>rawWaste</code> figure (<code>buildResourceGroup</code>), grouped again by <code>rawWaste.unit</code> so a <code>bytes</code> total never gets summed against a <code>coreHours</code> total under one type. <code>count</code> covers findings with neither (<code>buildCountGroup</code>), a plain per-impact-band tally with no magnitude claim at all. Each <code>RollupGroup</code> also carries its own <code>findings: Finding[]</code> (the exact members that fed the aggregate), which <code>FixTheseFirst.tsx</code> reads directly to pick a group&#39;s highest-impact member and to render its expanded, paginated list.</p><p>A type only contributes a tier when it has at least one finding of that kind; most types produce exactly one tier, but a type whose formula varies by <code>variant</code>/<code>rule</code> (e.g. <code>memoryUtilization</code>, see the coverage table below) can produce more than one.</p><p><code>cachingOpportunity</code> and <code>cacheUtilization</code> are both <code>cost-only</code>: <code>basis: &#39;resourceOnly&#39;</code>, <code>wallClock: null</code>, but <code>rawWaste.unit</code> is <code>&#39;ms&#39;</code>, the same unit a real <code>wallClock</code> figure would use, because their formula&#39;s natural output happens to be time (a re-read cost), not because either finding makes a wall-clock claim. Left unlabeled, a <code>resource</code>-tier &quot;ms&quot; total sitting next to a <code>time</code>-tier &quot;recoverable time&quot; total would read as directly comparable when it isn&#39;t: the resource figure was never gate-clipped against any stage&#39;s occupancy, so it can exceed what the stage actually spent. <code>rollupGroupStat</code> (same module) calls this out in the trailing-stat copy <code>FixTheseFirst.tsx</code> renders: a <code>resource</code>-kind group (any unit, including <code>ms</code>) shows the finding count plus the summed raw waste (omitted when it reads as zero), and its tooltip calls the total &quot;a resource cost, not run time&quot;. It never says &quot;recoverable&quot;, so the two ms-shaped numbers are never mistaken for the same kind of claim.</p><h2 id="per-formula-spot-checks" tabindex="-1">Per-formula spot-checks <a class="header-anchor" href="#per-formula-spot-checks" aria-label="Permalink to &quot;Per-formula spot-checks&quot;">​</a></h2><table tabindex="0"><thead><tr><th>Detector</th><th>Formula basis</th><th>Spot-check</th></tr></thead><tbody><tr><td>gc</td><td><code>jvmGCTime / (executorRunTime / stageDurationMs)</code></td><td><code>private-log-01.zstd</code>, stage 507: <code>jvmGCTime</code>=1080ms, <code>executorRunTime</code>=27509ms, <code>stageDurationMs</code>=56279ms → <code>wasteMs</code> = 1080 / (27509/56279) ≈ 2209.5ms. That&#39;s ≈3.9% of the stage&#39;s 56.3s wall-clock duration, matching the finding&#39;s own reported <code>gcPct</code> (3.9%) exactly, as the formula guarantees by construction. Under the occupancy model this stage&#39;s <code>gate</code> is <code>0.041</code> (0.04145044590332269 exactly): <code>basis: &#39;contended&#39;</code>, <code>wallClock: {low: 91.6, high: 2209.5}</code> (91.5850382272182 / 2209.506706895925 exactly).</td></tr><tr><td>shuffle</td><td><code>shuffleReadBytes / (SHUFFLE_THROUGHPUT_BPS × executors that ran the stage)</code></td><td><code>private-log-02.zstd</code>, stage 99 (<code>SHFL</code> finding): <code>shuffleReadBytes</code>=204,172,518,504 over 8 executors → <code>wasteMs</code> = 204172518504 / (8 × 125,000,000) × 1000 ≈ 204,173ms, against the stage&#39;s 763,776ms duration. The tasks&#39; own measured shuffle fetch wait on this stage is 25.2s of wall-clock (<code>fetchWaitTime</code> / average concurrency), so even the per-link model runs well above the network stall actually observed, and the claim is capped there: 25.2s, <code>measured</code>.</td></tr><tr><td>spill</td><td><code>diskBytesSpilled / (SPILL_IO_THROUGHPUT_BPS × executors that ran the stage)</code></td><td>Same run and stage (99): <code>diskBytesSpilled</code>=145,978,433,675 (note: the <code>SPILL</code> finding&#39;s own <code>value</code>/<code>metric</code> report <code>memoryBytesSpilled</code>=913,686,966,448, ~6x larger; the formula correctly uses the smaller disk figure, not that one) over 8 executors → <code>wasteMs</code> = 145978433675 / (8 × 200,000,000) × 1000 ≈ 91,237ms (91236.521046875 exactly, matching <code>wallClock</code>, below the clip).</td></tr></tbody></table><h2 id="overlap-caveat-skew-straggler" tabindex="-1">Overlap caveat: skew / straggler <a class="header-anchor" href="#overlap-caveat-skew-straggler" aria-label="Permalink to &quot;Overlap caveat: skew / straggler&quot;">​</a></h2><p><code>skew</code> and <code>straggler</code> both claim the stage&#39;s <code>tailReplayRecoveryMs</code> (see <a href="#occupancy-weighted-attribution">Occupancy-weighted attribution</a>), whichever skew branch fired, so when both fire on one stage they report the same recovered tail. The skew branch (P95 or max over P50) changes only the fallback single-task delta on a stage without the replay field. This phase does not dedupe or suppress either: each keeps its own independently-computed <code>wallClock</code>. Do not sum <code>wallClock.high</code> across multiple findings on the same stage: if both fire together, they describe the same underlying waste, not two separate wastes. Both are clipped with the post-fix floor described under <a href="#occupancy-weighted-attribution">Occupancy-weighted attribution</a>, not the plain <code>ceiling</code>, so a stage gated by one dominant outlier task reports that task&#39;s excess as recoverable instead of the near-zero room <code>ceiling &gt;= taskDurationMax</code> would leave.</p><p><code>analyzer.ts</code>&#39;s <code>flagSkewStragglerOverlap</code> (run after <code>deriveImpactBand</code>, once per <code>analyze()</code> call) states this caveat on the findings themselves: whenever <code>skew</code> (either branch) and <code>straggler</code> both fire on the same <code>stageId</code>, it appends a &quot;this overlaps with the X finding on this stage&quot; sentence to both findings&#39; <code>validationRequired</code> text (rather than suppressing either, so neither finding&#39;s own diagnostic value is lost). The note rides the same confidence-caveat UI (<code>RowStatusCluster</code>) a reader already sees before trusting either finding&#39;s magnitude, since both detectors also carry a <code>confidence</code> field that scales <code>low</code>/<code>medium</code>/<code>high</code> off how far the finding&#39;s own ratio (skew: <code>ratioWarn</code>) or task share (straggler: <code>shareWarn</code>/<code>warnPct</code>, <code>critPct</code>) sits past its detector threshold (unvalidated; see the confidence-disclosure note in detector-contract.md).</p><p><code>stageShape</code>&#39;s <code>taskStageSkew</code> rule doesn&#39;t participate in this caveat: it reports a <code>resourceOnly</code> idle-core-ms figure (see the coverage table below) instead of a wall-clock claim, so there&#39;s nothing left to double-count against <code>skew</code>/<code>straggler</code>. It fires on the same straggler tail those two price (on the corpus, each of its 6 firings shares a stage with a <code>skew</code> or <code>straggler</code> finding), which is why it stays off the wall-clock path.</p><h2 id="tuned-thresholds" tabindex="-1">Tuned thresholds <a class="header-anchor" href="#tuned-thresholds" aria-label="Permalink to &quot;Tuned thresholds&quot;">​</a></h2><p>The estimates are calibrated against the default detector thresholds: the spot-checks above, the corpus snapshot and the tail-replay scores (<code>dev/eval-tail-replay.mjs</code>) all measure findings the defaults produce. The formulas read the stage&#39;s own figures, not the thresholds, but a threshold decides which findings exist, so an override that loosens one produces findings, near its new gate, that no estimate here was checked against. When the CLI or the MCP server runs with <code>--thresholds</code> (see <a href="./detector-contract.html#tuning-thresholds">Tuning thresholds</a>), every finding from a tuned detector carries <code>tunedThresholds</code>, a finding with an estimate figure gets a <code>validationRequired</code> sentence saying that estimate is unvalidated, and the report header lists the tuned detectors. The estimate itself is computed the same way; nothing rescales or drops it. The impact bands (<code>impact-band.ts</code>) keep their default floors on a tuned run, except that a tuned <code>skew</code> or <code>straggler</code> <code>floorPctWarn</code>/<code>floorPctCrit</code> grades that entry&#39;s own findings.</p><h2 id="per-finding-type-coverage" tabindex="-1">Per-finding-type coverage <a class="header-anchor" href="#per-finding-type-coverage" aria-label="Permalink to &quot;Per-finding-type coverage&quot;">​</a></h2><p>One row per distinct <code>type</code> string <code>packages/core/src/detectors.ts</code> actually emits: every entry must declare an <code>estimate()</code> (the compiler rejects one without it), so every row below has one, and the table itself is the coverage count, not a number restated here. <code>broadcastSizing</code> is a <code>DETECTORS</code> entry label only, and the plan-walk it drives emits <code>overBroadcast</code>/<code>underBroadcast</code> findings instead, so those two are the rows that appear, not <code>broadcastSizing</code> itself. Tag meanings: <code>measured</code> and <code>modeled</code> both produce a real, gate-clipped, non-<code>{0,0}</code><code>wallClock</code> (the difference is whether the formula&#39;s inputs are recorded per-stage fields or an assumed constant like a throughput figure); <code>cost-only</code> always reports <code>basis: &#39;resourceOnly&#39;</code>, <code>wallClock: null</code> but carries its real signal in <code>rawWaste</code>; <code>informational-only</code> reports <code>basis: &#39;informational&#39;</code>, <code>wallClock: null</code> with no <code>rawWaste</code> at all, since there&#39;s nothing quantifiable. A type with more than one tag fires a different formula per <code>variant</code>/<code>rule</code> on the same finding type; the basis column says which.</p><table tabindex="0"><thead><tr><th>Finding type</th><th>Scope</th><th>Tag</th><th>Basis</th></tr></thead><tbody><tr><td><code>retryWaste</code></td><td>stage</td><td>measured / modeled</td><td><code>retryWasteMs</code> is summed attempt time, and attempts of different tasks run side by side (one lost executor fails every task it was running at once). With every wasted attempt sampled (<code>retryTaskSamples</code>, capped at 20), modeled: the longest retry chain (highest sampled attempt number + 1) times the mean wasted attempt, or <code>retryWasteMs / peakConcurrentTasks</code> when larger, capped at <code>retryWasteMs</code>. Otherwise measured, <code>retryWasteMs</code> itself. Gate-clipped; the summed figure is kept as <code>rawWaste</code> in <code>ms</code>.</td></tr><tr><td><code>speculationWaste</code></td><td>stage</td><td>measured</td><td><code>speculationWasteMs</code>, gate-clipped; pre-clip figure kept as <code>rawWaste</code> in <code>ms</code></td></tr><tr><td><code>coldStart</code></td><td>app</td><td>measured</td><td><code>gapSeconds × 1000</code> (first stage submitted to first executor added), unclipped, <code>basis: &#39;serial&#39;</code> unconditionally (a pre-first-task gap can&#39;t overlap any stage)</td></tr><tr><td><code>gc</code></td><td>stage</td><td>modeled / informational-only</td><td>high-GC: <code>jvmGCTime / (executorRunTime / stageDurationMs)</code>, gate-clipped: the concurrency division is an approximation, not a reconstruction, hence <code>modeled</code>; <code>rawWaste</code> in <code>coreMs</code> is the raw <code>jvmGCTime</code> sum before that conversion. Low-GC (<code>direction: &#39;low&#39;</code>): informational-only, since its fix (less executor memory) raises GC rather than recovering it</td></tr><tr><td><code>skew</code></td><td>stage</td><td>measured</td><td><code>tailRecoveryMs</code>: the stage&#39;s <code>tailReplayRecoveryMs</code> (a task-level replay with every task over 4× P50 capped at P50), gate-clipped against the post-fix floor (<code>shortensLongestTask</code>) with the longest task the fix leaves as a floor (<code>longestTaskAfterFixMs</code>, as for <code>straggler</code>); pre-clip figure kept as <code>rawWaste</code> in <code>ms</code></td></tr><tr><td><code>straggler</code></td><td>stage</td><td>measured</td><td><code>tailRecoveryMs</code>, as for <code>skew</code> (0 on a speculation-driven stage with no task over 4× P50), gate-clipped against the post-fix floor (<code>shortensLongestTask</code>, <code>longestTaskAfterFixMs</code>)</td></tr><tr><td><code>stageShape</code></td><td>stage</td><td>cost-only</td><td>all three rules are <code>estimateMethod: &#39;measured&#39;</code>, real per-stage fields, no assumed constant: <code>&#39;lowParallelism&#39;</code> → <code>rawWaste</code> in <code>coreMs</code> (idle cores × stage duration); <code>&#39;dataExplosion&#39;</code> → <code>rawWaste</code> in <code>bytes</code> (<code>outputBytes − inputBytes</code>); <code>&#39;taskStageSkew&#39;</code> → <code>rawWaste</code> in <code>coreMs</code> (<code>max(0, min(totalCores, taskCount) − 1) × (taskDurationMax − taskDurationP50)</code>, the cores idle during the straggler&#39;s tail at achieved concurrency; <code>totalCores</code> here is the finding&#39;s own figure: the executors that ran the stage × <code>spark.executor.cores</code>, not the run&#39;s peak concurrent cores)</td></tr><tr><td><code>slowHost</code></td><td>stage</td><td>measured / informational-only</td><td>duration-based variants: the slow host&#39;s absolute figure minus the stage median, <code>max(0, hostMeanMs − taskDurationP50)</code> for <code>hostMeanRatio</code>/<code>durationShare</code> and <code>max(0, execMaxValue − taskDurationP50)</code> for <code>multiDim</code>+<code>taskTime</code> (<code>value</code> is a ratio or share, never ms), gate-clipped, pre-clip figure kept as <code>rawWaste</code> in <code>ms</code>; byte-based <code>multiDim</code> dimensions: no formula yet (<code>estimateMethod: &#39;none&#39;</code>)</td></tr><tr><td><code>duplicatePlanSubtree</code></td><td>sql</td><td>measured</td><td>per stage in <code>stageShares</code>: its task-active time (<code>taskActiveMs</code>, the union of its task intervals; submit-to-complete only when absent) × the repeated operators&#39; share of that stage × the redundant fraction <code>(occurrences − 1) / occurrences</code>, summed and capped at the union of those stages&#39; spans. A stage shared with other operators (the consuming join, the other join side) contributes only its share, so sibling groups can&#39;t claim one stage twice, and a stage left waiting for cores (2491 s open, 60 s of tasks on a real log) claims only its task time. No claim (<code>informational</code>) when the repeats&#39; details differ (<code>occurrencesIdentical: false</code>) or no repeated operator has a stage (the execution-wide <code>stageIds</code> fallback stays for linking only).</td></tr><tr><td><code>shuffle</code></td><td>stage</td><td>modeled / measured</td><td><code>shuffleReadBytes / (SHUFFLE_THROUGHPUT_BPS × executors)</code> (assumed ~125MB/s per executor link; <code>executors</code> = <code>executorStats.length</code>, the executors that ran the stage, min 1), capped at the fetch wait the tasks measured in wall-clock (<code>fetchWaitTime / (executorRunTime / stage duration)</code>, the <code>gc</code> conversion; <code>measured</code> when that cap binds), gate-clipped; <code>rawWaste</code> in <code>bytes</code> is the measured <code>shuffleReadBytes</code> behind it. The link model can&#39;t see whether reads stalled the tasks, which is why the fetch-wait cap applies. Fetch wait misses disk reads and deserialization, so the cap is a floor on what a shuffle costs, not its total</td></tr><tr><td><code>spill</code></td><td>stage</td><td>modeled</td><td><code>diskBytesSpilled / (SPILL_IO_THROUGHPUT_BPS × executors)</code> (assumed ~200MB/s per executor&#39;s local disk, same executor count as <code>shuffle</code>), gate-clipped; <code>rawWaste</code> in <code>bytes</code> is <code>diskBytesSpilled</code>, which is the number the formula uses and not the <code>memoryBytesSpilled</code> the finding&#39;s own <code>metric</code> displays</td></tr><tr><td><code>stageSlowness</code></td><td>stage</td><td>modeled</td><td>what more partitions (the finding&#39;s recommendation) could recover: the stage&#39;s task-active time (<code>taskActiveMs</code>, the union of its tasks&#39; launch-to-finish intervals from <code>finalizeStage</code>) × <code>max(0, 1 − taskCount / totalCores)</code>, gate-clipped with the post-fix floor (<code>shortensLongestTask</code>: splitting partitions splits the longest task too). A stage that already ran at least as many tasks as the cluster had cores claims 0, and so does one that read no input and no shuffle bytes and whose tasks spent under 1% of <code>executorRunTime</code> on CPU (<code>executorCpuTime</code>): they sat waiting on something outside Spark, which more partitions don&#39;t split (a 1-task JDBC <code>count</code> stage open 27 minutes on 5s of CPU, 0.33%, claims 0). Non-Python stages under 1% CPU share are JDBC reads, file listings and Delta log reads, and file writes start at 2%. The share is skipped, keeping the claim, when the log records no CPU time (older Spark) or the stage ran Python through <code>PythonRDD</code>, whose worker CPU the JVM metric misses (0.1% on computing stages); a Python UDF inside a SQL stage that reads no bytes isn&#39;t detected and can still read idle. A stage that read input or shuffle bytes keeps its claim whatever its CPU share, so a Python UDF over real input is never zeroed. Time a stage sat open with no task running (queued for slots) claims nothing. No cluster core count: informational.</td></tr><tr><td><code>partitionSizing</code></td><td>stage</td><td>modeled</td><td><code>maxPartitionTooBig</code>/<code>shufflePartitionSkew</code>: shuffle-throughput formulas, gate-clipped. <code>lowShuffleParallelism</code>: stage duration scaled down by the shortfall between actual and ideal-partition-count task counts (<code>stageDurationMs × (1 − taskCount / targetTaskCount)</code>), i.e. the serialized work more partitions would let run concurrently, not the scheduling cost of the tasks you&#39;d add to fix it</td></tr><tr><td><code>tinyTask</code></td><td>stage</td><td>measured / modeled</td><td>excess task count over 10% of the stage&#39;s actual count, × the stage&#39;s own measured per-task overhead (summed task wall time from <code>executorStats</code> minus <code>executorRunTime</code>, over <code>taskCount</code>), ÷ the stage&#39;s achieved task concurrency (task time ÷ stage duration, floored at 1), gate-clipped; <code>measured</code>. A stage with no <code>executorStats</code> or no measurable overhead falls back to the assumed 50ms per task, undivided (<code>modeled</code>). Pre-clip figure kept as <code>rawWaste</code> in <code>ms</code>.</td></tr><tr><td><code>smallFiles</code></td><td>sql</td><td>modeled / cost-only</td><td><code>fileCount × FILE_OPEN_OVERHEAD_MS</code> (10 ms per file), divided for a read by the most tasks its stages ran at once (<code>peakConcurrentTasks</code>: tasks open their files in parallel; 91,344 files claimed 76 s on a 117 s stage that ran 314 tasks at once) and kept serial for a write (the job commit moves each file on the driver). The figure is split evenly across <code>stageIds</code>, each stage&#39;s share is gate-clipped, and the sum is capped at the stages&#39; union; with no <code>stageIds</code> to map to, cost-only with that same figure as <code>rawWaste</code> in <code>ms</code>. <code>stageIds</code> is narrowed the same way (see <a href="./detector-contract.html#stage-id-attribution-for-plan-advisor-findings">Stage-ID attribution for Plan Advisor findings</a>); falls back to the whole execution&#39;s stages when the flagged node(s) have no accumulator coverage.</td></tr><tr><td><code>overBroadcast</code></td><td>sql</td><td>modeled / cost-only</td><td><code>broadcastBytes / BROADCAST_BANDWIDTH_BPS</code>, summed and capped over <code>stageIds</code>&#39;s union; cost-only with <code>rawWaste</code> in <code>ms</code> when not stage-mappable. <code>stageIds</code> is narrowed the same way; falls back to the whole execution&#39;s stages when the flagged node(s) have no accumulator coverage.</td></tr><tr><td><code>underBroadcast</code></td><td>sql</td><td>modeled / cost-only</td><td><code>smallerSideBytes / BROADCAST_BANDWIDTH_BPS</code>, summed and capped over <code>stageIds</code>&#39;s union; cost-only with <code>rawWaste</code> in <code>ms</code> when not stage-mappable. <code>stageIds</code> is narrowed the same way; falls back to the whole execution&#39;s stages when the flagged node(s) have no accumulator coverage.</td></tr><tr><td><code>memoryUtilization</code></td><td>app</td><td>cost-only / informational-only</td><td>Three of the four variants report <code>rawWaste</code> in <code>mbSeconds</code>: <code>variant: &#39;wasteModel&#39;</code> passes through its own <code>wastedMBSeconds</code>; <code>&#39;idleCores&#39;</code> uses <code>idleRateFraction × allocatedMB × peakExecutors × appDurationSeconds</code>; <code>&#39;memoryBand&#39;</code> with <code>rule: &#39;heapOverProvisioned&#39;</code> uses <code>(allocatedBytes − heap) in MB × appDurationSeconds</code>. <code>&#39;memoryBand&#39;</code> with <code>rule: &#39;heapNearCapacity&#39;</code> is an OOM-risk signal rather than a waste, and the <code>dataUnavailable</code> shape has no inputs at all: both informational-only</td></tr><tr><td><code>utilization</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreHours</code>: <code>(1 − utilizationFraction) × appDurationMs × totalCores / 3.6e6</code></td></tr><tr><td><code>coreLocality</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreMs</code>: <code>nonLocalTaskCount × NETWORK_FETCH_PENALTY_MS</code></td></tr><tr><td><code>autoscalingChurn</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreHours</code>: <code>shortLivedExecutorCount × EXECUTOR_STARTUP_OVERHEAD_MS / 3.6e6</code></td></tr><tr><td><code>configAudit</code></td><td>config</td><td>informational-only</td><td>a config-drift check standing alone; no waste formula</td></tr><tr><td><code>jobFailureRate</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>coreHours</code>: <code>failedJobCount × avgJobDurationMs / 3.6e6</code></td></tr><tr><td><code>cachingOpportunity</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>ms</code>: <code>totalReadBytes / RE_READ_THROUGHPUT_BPS</code></td></tr><tr><td><code>cacheUtilization</code></td><td>app</td><td>cost-only</td><td><code>rawWaste</code> in <code>ms</code>: uncached-or-spilled bytes <code>/ RE_READ_THROUGHPUT_BPS</code>, where the never-cached partitions&#39; bytes are extrapolated from the cached partitions&#39; own average size (<code>memorySize + diskSize</code>, over <code>numCachedPartitions</code>), plus <code>diskSize</code> again for the already-cached-but-on-disk partitions&#39; own re-read cost. The <code>storageUnobserved</code> caveat (<code>dataUnavailable</code>) has no sizes: informational-only</td></tr><tr><td><code>stageFailed</code></td><td>stage</td><td>informational-only</td><td>no waste formula</td></tr><tr><td><code>failures</code></td><td>stage</td><td>informational-only</td><td>no waste formula</td></tr><tr><td><code>incompleteRun</code></td><td>app</td><td>informational-only</td><td>no waste formula</td></tr></tbody></table>',38)])])}const p=t(s,[["render",c]]);export{g as __pageData,p as default};
@@ -1 +0,0 @@
1
- import{_ as t,o,c as a,a5 as d}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Impact estimation","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation.md","filePath":"contributor-guide/architecture/impact-estimation.md"}'),s={name:"contributor-guide/architecture/impact-estimation.md"};function c(r,e,i,n,l,h){return o(),a("div",null,[...e[0]||(e[0]=[d("",38)])])}const p=t(s,[["render",c]]);export{g as __pageData,p as default};
@@ -1 +0,0 @@
1
- import{_ as t,o as r,c as a,a5 as i}from"./chunks/framework.DSg0KOwT.js";const m=JSON.parse('{"title":"Architecture","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/index.md","filePath":"contributor-guide/architecture/index.md"}'),o={name:"contributor-guide/architecture/index.md"};function c(n,e,s,h,l,d){return r(),a("div",null,[...e[0]||(e[0]=[i('<h1 id="architecture" tabindex="-1">Architecture <a class="header-anchor" href="#architecture" aria-label="Permalink to &quot;Architecture&quot;">​</a></h1><p>These pages cover one concern each: the two-actor overview, the worker protocol, state plus History Server intake, the detector contract, impact estimation, the fixed widget rendering order, the board&#39;s widgets, and the stage and plan drill-downs.</p><p>Where to start:</p><ul><li><a href="./overview.html#two-actors">Two actors</a>: the parser worker and the view, and why the split exists.</li><li><a href="./detector-contract.html#detector-contract">Detector contract</a>: the interface every bottleneck detector implements. Read this before adding a finding.</li><li><a href="./impact-estimation.html#impact-estimation">Impact estimation</a>: how findings get a wall-clock/resource waste estimate attached.</li><li><a href="./../testing.html#testing-layout">Testing layout</a>: where tests live and what each layer covers.</li><li><a href="./../contributing.html">Contributing</a>: where new docs go, changesets and the release flow.</li></ul>',4)])])}const p=t(o,[["render",c]]);export{m as __pageData,p as default};
@@ -1 +0,0 @@
1
- import{_ as o,o as t,c as a,a5 as s}from"./chunks/framework.DSg0KOwT.js";const m=JSON.parse('{"title":"Architecture overview","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/overview.md","filePath":"contributor-guide/architecture/overview.md"}'),r={name:"contributor-guide/architecture/overview.md"};function c(d,e,i,n,l,p){return t(),a("div",null,[...e[0]||(e[0]=[s('<h1 id="architecture-overview" tabindex="-1">Architecture overview <a class="header-anchor" href="#architecture-overview" aria-label="Permalink to &quot;Architecture overview&quot;">​</a></h1><h2 id="core-invariant" tabindex="-1">Core invariant <a class="header-anchor" href="#core-invariant" aria-label="Permalink to &quot;Core invariant&quot;">​</a></h2><p>Task data must never live on the main thread. A Spark job can emit millions of <code>SparkListenerTaskEnd</code> events (240 MB+ of metrics); parsing them on the UI thread freezes Chrome. The design below enforces that in both deploy modes: the static zero-backend app, and the optional local-server mode in <code>packages/server/</code>, which also proxies Spark History Server fetches to sidestep CORS.</p><h2 id="two-actors" tabindex="-1">Two actors <a class="header-anchor" href="#two-actors" aria-label="Permalink to &quot;Two actors&quot;">​</a></h2><h3 id="main-thread" tabindex="-1">Main thread <a class="header-anchor" href="#main-thread" aria-label="Permalink to &quot;Main thread&quot;">​</a></h3><p>Owns the DOM (React) and a summary <code>AppModel</code> (jobs, stages, sql, executors, no raw tasks), held in a Zustand store (<code>src/store/store.ts</code>).</p><p><code>src/store/useIngest.ts</code> is the composition root. It owns the ingest client&#39;s lifecycle (<code>createIngestClient</code>, <code>@sparkforensics/core/ingest.ts</code>) and wires <code>model-assembler.ts</code>&#39;s <code>createModelCallbacks</code> (worker message → <code>AppModel</code>) into store updates: <code>onProgress</code> → <code>parse</code>; <code>onDone</code> → derives <code>evidenceAvailability</code> from the final normalized model plus <code>done.skippedLines</code>, runs <code>analyzer.ts</code>, sets <code>catalog</code>, and prefetches flagged-stage task data.</p><p><code>src/App.tsx</code> routes on store state: a two-run compare load, the plan-graph route and the run comparison take precedence; otherwise idle/error → <code>CompareLanding</code> (one or two <code>DropZone</code>s), parsing → a live progress readout (an SHS parse keeps its intake form instead), ready → the lazy-loaded <code>Dashboard</code>. <code>src/view/Dashboard.tsx</code> renders widgets from <code>src/view/detector-registry.tsx</code>&#39;s <code>REGISTRY</code> (see <a href="./widget-rendering.html">Widget rendering</a>).</p><h3 id="worker-web-worker-packages-core" tabindex="-1">Worker (Web Worker, <code>packages/core</code>) <a class="header-anchor" href="#worker-web-worker-packages-core" aria-label="Permalink to &quot;Worker (Web Worker, `packages/core`)&quot;">​</a></h3><p>Owns the file and <code>taskStore: Map&lt;stageId, Float64Array&gt;</code>, keyed by 8-field stride <code>[duration, gcTime, memSpilled, diskSpilled, shuffleRead, shuffleWrite, launchTime, finishTime]</code>. Six modules, one of them the rolling-log helper:</p><ul><li><code>packages/core/src/stage-quantiles.ts</code>, a pure leaf, exporting <code>finalizeStage</code>, <code>computeFieldQuantiles</code>, <code>computeDurationQuantiles</code>, <code>classifySpill</code>, <code>FIELDS</code>, and <code>TASK_FIELD_NAMES</code> for the task-field packed-array layout.</li><li><code>packages/core/src/event-handlers.ts</code>, holding <code>processEvent</code> and the per-event handler functions, <code>accumulateTask</code>, <code>createState</code>, <code>dispatchLine</code>, <code>buildChunkDecoder</code>, <code>emitParseCompletion</code>, <code>collectStageExecutorMetrics</code>, <code>collectLateSpeculationWaste</code>.</li><li><code>packages/core/src/shs-fetch.ts</code>, the SHS zip-fetch/decompress path: <code>runParseFromUrl</code>, <code>decodeShsArchive</code>, <code>parseZipArchive</code>, <code>sniffCodec</code>.</li><li><code>packages/core/src/rolling-log-reassembly.ts</code>, import-free on purpose so the main-thread <code>DropZone</code> can use it: <code>naturalCompare</code>, <code>reassembleRollingEntries</code>.</li><li><code>packages/core/src/zip-archive.ts</code>, the random-access zip reader both zip paths use: <code>isZip</code>, <code>listZipEntries</code>, <code>streamZipEntry</code>.</li><li><code>packages/core/src/parser-worker.ts</code>, a thin entrypoint (<code>streamFile</code>, <code>runParse</code>, <code>runParseFiles</code>, the <code>isWorker</code>/<code>self.onmessage</code> bus) that re-exports the public parts of <code>event-handlers.ts</code>, <code>stage-quantiles.ts</code>, <code>shs-fetch.ts</code> and <code>rolling-log-reassembly.ts</code>. It is the only piece needing the File/Blob streaming API.</li></ul><p>Dropped zstd files also run a nested decompress worker, <code>packages/core/src/zstd-worker.ts</code>, driven from the parse worker by <code>packages/core/src/zstd-worker-client.ts</code> (see <a href="./worker-protocol.html#decompress-worker">Decompress worker</a>).</p><h2 id="streaming" tabindex="-1">Streaming <a class="header-anchor" href="#streaming" aria-label="Permalink to &quot;Streaming&quot;">​</a></h2><p>The worker reads the <code>File</code> in 512 KB slices (smaller for small files, so every file gets at least 100 progress steps) via <code>file.slice(...).arrayBuffer()</code>, decodes each decompressed chunk with one streaming <code>TextDecoder</code>, splits on <code>\\n</code>, JSON-parses and dispatches per event type. Quantiles (P50/P95/max) and spill classification (<code>skew | volume | unclassified</code>) are computed at <code>SparkListenerStageCompleted</code> time, before posting the stage&#39;s <code>{ type: &#39;stage&#39; }</code> message to main.</p><p>When <code>spark.eventLog.logStageExecutorMetrics=true</code> (default <code>false</code>), <code>SparkListenerStageExecutorMetrics</code> events populate <code>stage.executorMetrics: Map&lt;execId, {...}&gt;</code> with the 23 raw peak-memory/GC fields verbatim (camelCased), consumed by the <code>memoryUtilization</code> detector&#39;s per-executor memory bands (see <a href="./board-widgets.html">Memory Utilization</a>).</p><p>These events can arrive <em>after</em> <code>SparkListenerStageCompleted</code> for the same stage, so the per-stage <code>stage</code> message posted at completion time never carries them. Instead, <code>collectStageExecutorMetrics(state)</code> walks every stage once more just before <code>done</code>, and the worker re-posts a single <code>stageExecutorMetrics</code> message (<code>Map&lt;stageId, Map&lt;execId, metrics&gt;&gt;</code>, empty when the log has no such data). Main-thread consumers get every stage&#39;s metrics without a per-stage race.</p><p>A TaskEnd that arrives after its stage&#39;s StageCompleted is otherwise dropped, since the finalized stage already posted its stats. The exception is the losing copy of a speculative race: Spark kills it only once the stage finishes (&quot;Stage cancelled: Stage finished&quot;), so its TaskEnd is normally late. <code>accumulateTask</code> adds its time to the stage&#39;s <code>speculationWasteMs</code> and <code>speculationWastedAttempts</code> and nothing else, pairing it with a speculative winner kept for that purpose. <code>collectLateSpeculationWaste(state)</code> re-posts the updated totals of those stages once, just before <code>done</code>, as a <code>stageSpeculationWaste</code> message (<code>Map&lt;stageId, { speculationWasteMs, speculationWastedAttempts }&gt;</code>, empty when no speculative attempt ended late). Any other late task of a failed attempt (a zombie or killed task) leaves the stage&#39;s own figures alone and adds to its <code>lateAttemptWork</code>, which only the CLI&#39;s <code>metrics</code> block reads; <code>collectLateAttemptWork(state)</code> re-posts it as a <code>stageLateAttemptWork</code> message (<code>Map&lt;stageId, StageAttemptTotals&gt;</code>). <code>emitParseCompletion</code> sends the speculation totals after <code>runAggregates</code> (whole-run aggregates computed from <code>taskStore</code>) and before the final <code>app</code> and <code>done</code> messages.</p><p>Compressed logs are decompressed as they stream. <code>sniffCodec</code> reads the leading magic bytes: gzip (<code>1f 8b</code>), Zstandard (<code>28 b5 2f fd</code>, Spark&#39;s <code>spark.io.compression.codec=zstd</code>), Spark&#39;s custom <code>LZ4Block</code> framing, or Spark&#39;s Snappy framing (<code>org.xerial.snappy</code>&#39;s <code>\\x82SNAPPY\\0</code> header, <code>spark.io.compression.codec=snappy</code>). Only a zip entry falls back to its name&#39;s suffix (see <a href="#zip-archives">Zip archives</a>). Both the dropped-file path and the SHS-fetch path stream block-by-block (fflate <code>Gunzip</code> / fzstd <code>Decompress</code> / the LZ4Block decoder / the Snappy block decoder) to keep one decompressed chunk live at a time. In the browser, a dropped zstd file decompresses in a second worker while the parse worker parses earlier output, with a three-slice window bounding what is queued between them (see <a href="./worker-protocol.html#decompress-worker">Decompress worker</a>). The Node CLI/MCP paths (<code>collectRun</code> for local files, <code>shs-load.ts</code> for SHS archives) swap fzstd for Node&#39;s native zlib zstd where the running Node has it (22.15+/23.8+), through the <code>zstdDecoder</code> option of <code>runParse</code>/<code>decodeShsArchive</code>: <code>packages/core/src/cli/native-zstd.ts</code> decompresses one frame at a time, since Node&#39;s own decoders stop after a stream&#39;s first frame and Spark writes thousands of small ones. A frame declaring more than 64 MiB of content, one still incomplete after 64 MiB compressed, a malformed one, or a truncated tail goes to fzstd instead. Spark&#39;s frames declare no content size, so each one&#39;s output is capped at 64 MiB as it decodes: past that, the inline decoder hands the frame to fzstd and the threaded one streams it, pausing while 64 MiB wait to be parsed. For local files (<code>nodeParseCodecs</code>, <code>createThreadedZstdDecoder</code>), frames of 64 KB or more compressed decompress on libuv&#39;s threadpool, up to 4 at a time, while the main thread parses earlier output; <code>streamFile</code> awaits each <code>push</code>, and chunks still arrive in stream order. SHS archives fetched from a History Server keep the inline decoder (<code>nodeArchiveCodecs</code>).</p><p>Rolling <code>eventlog_v2_*</code> directories (Spark&#39;s multi-file event-log format, <code>events_&lt;index&gt;_&lt;appId&gt;(.codec)</code> files plus a zero-byte <code>appstatus_*</code> completion marker and, periodically, one <code>*.compact</code> merge file) are reassembled into parse order by <code>reassembleRollingEntries(names)</code>: drop the marker, drop every non-compact <code>events_*</code> file at or below the most recent <code>.compact</code> file&#39;s index, sort the rest numerically, and throw if an index is missing. The zip path (<code>parseZipArchive</code>) calls it on member names; for a dropped folder, <code>DropZone</code> (and the CLI&#39;s <code>collectRun</code>) call it before handing the ordered files to <code>runParseFiles</code>.</p><p><code>runParseFiles</code> streams each file in the resulting order through the same chunked codec dispatch as <code>runParse</code>, with a fresh decompressor per file, since codecs never span a roll boundary. The NDJSON line-decoder is the exception: it stays alive across all files, because nothing guarantees a roll boundary lands on a line boundary.</p><h2 id="zip-archives" tabindex="-1">Zip archives <a class="header-anchor" href="#zip-archives" aria-label="Permalink to &quot;Zip archives&quot;">​</a></h2><p>A History Server download is a zip, whether fetched by <code>runParseFromUrl</code> or dropped as a file (the Spark UI&#39;s download link, or <code>GET /api/v1/applications/&lt;appId&gt;/logs</code> saved to disk). Both go through <code>parseZipArchive</code> (<code>packages/core/src/shs-fetch.ts</code>): <code>runParse</code> checks for the zip signature before its codec dispatch, and <code>decodeShsArchive</code> wraps the downloaded bytes in the same <code>slice()</code> interface a <code>File</code> has. The two differ only in how they report failure (a plain <code>error</code> message for a dropped file, <code>{ source: &#39;shs&#39;, code: &#39;invalid-event-log&#39; }</code> for a fetch).</p><p><code>packages/core/src/zip-archive.ts</code> reads the central directory from the archive&#39;s tail, then streams one entry at a time through fflate&#39;s <code>UnzipInflate</code> in <code>chunkSize</code> slices. A dropped file is read a slice at a time, and no decompressed entry is ever held whole (the SHS fetch still buffers its download before parsing). It avoids fflate&#39;s forward-scanning <code>Unzip</code> on purpose: the History Server writes its zip with Java&#39;s <code>ZipOutputStream</code>, which leaves each local header&#39;s sizes blank and appends a data descriptor, so <code>Unzip</code> would have to find each entry&#39;s end by scanning compressed bytes for a signature, and it would hand rolling parts over in archive order (<code>events_10</code> before <code>events_2</code>). A single-file log is one entry at the root. A rolling log&#39;s parts sit under an <code>eventlog_v2_&lt;appId&gt;/</code> directory entry and are matched by base name. The zip must hold a single application attempt. Without an attempt ID the History Server packs every attempt into one download (several root entries, or several <code>eventlog_v2_</code> directories), and <code>parseZipArchive</code> rejects that before parsing with the attempt count and a pointer to <code>GET /api/v1/applications/&lt;appId&gt;/&lt;attemptId&gt;/logs</code>.</p><p>Each entry&#39;s bytes then go through the codec&#39;s streaming decoder, sniffed from the entry&#39;s first bytes, with the entry name&#39;s suffix as fallback. One-shotting the decompression of an entry (fzstd&#39;s <code>decompress()</code>, fflate&#39;s <code>gunzipSync()</code>) would allocate its full declared content size as one ArrayBuffer, which fails outright for a multi-GB event log. Decompressors are vendored under <code>packages/core/src/vendor/</code> (<code>fflate.js</code>, <code>fzstd.js</code>) plus <code>packages/core/src/lz4-block.ts</code> and <code>packages/core/src/snappy-block.ts</code>.</p>',24)])])}const f=o(r,[["render",c]]);export{m as __pageData,f as default};