sparkforensics-server 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/assets/AutoscalingChurn-CRA2Mb7F.js +1 -0
- package/public/assets/CacheUtilization-Bh9OY_V9.js +1 -0
- package/public/assets/{CachingOpportunity-D1NcHs0V.js → CachingOpportunity-oCiQwmTJ.js} +1 -1
- package/public/assets/{ColdStart-CdRJ9gD8.js → ColdStart-BJrtjfmn.js} +1 -1
- package/public/assets/ConfigAudit-DMvKxySP.js +1 -0
- package/public/assets/{CoreUsageArea-a--NTRGY.js → CoreUsageArea-REC2zsKw.js} +1 -1
- package/public/assets/Dashboard-BHZ76JCu.js +2 -0
- package/public/assets/DisclosureContext-OO7X1HAx.js +1 -0
- package/public/assets/{DuplicatePlanSubtree-dobXdz7E.js → DuplicatePlanSubtree-BgG-L-cK.js} +1 -1
- package/public/assets/ExecutorUtilization-D2KhSVbY.js +1 -0
- package/public/assets/{GcPressure-8-G2ri10.js → GcPressure-D1BTE8qh.js} +1 -1
- package/public/assets/{IncompleteRun-B91lky7a.js → IncompleteRun-DD40xUpy.js} +1 -1
- package/public/assets/{JobFailures-BsWvBMyJ.js → JobFailures-YwNzCF19.js} +1 -1
- package/public/assets/MemoryUtilization-9b8ee_3_.js +1 -0
- package/public/assets/{OverBroadcast-DdN8JIJH.js → OverBroadcast-CfctS8a8.js} +1 -1
- package/public/assets/{PartitionSizing-CY5HDYtp.js → PartitionSizing-VG8kBgG5.js} +1 -1
- package/public/assets/{PlanExplorer-DgHCDBD_.js → PlanExplorer-DmubTAOP.js} +1 -1
- package/public/assets/PlanGraphRoute-DctPZjS0.js +1 -0
- package/public/assets/RetryWaste-CnpM8bB7.js +1 -0
- package/public/assets/{ShuffleIO-MMnDESkF.js → ShuffleIO-D13vGmas.js} +1 -1
- package/public/assets/Skew-DlagstYP.js +1 -0
- package/public/assets/SlowHost-cSkLRZ_e.js +1 -0
- package/public/assets/{SmallFiles-BoK3R1Ex.js → SmallFiles-B-kERMcq.js} +1 -1
- package/public/assets/{SpeculationWaste-CnVxB5AJ.js → SpeculationWaste-DaysAzXO.js} +1 -1
- package/public/assets/Spill-H44Gz4US.js +1 -0
- package/public/assets/{StageFailed-mAhHU4l_.js → StageFailed-DCfii9h_.js} +1 -1
- package/public/assets/{StageFindingGroup-BEtPWcT0.js → StageFindingGroup-BXJ0Mj5n.js} +1 -1
- package/public/assets/{StageHeader-DJkYkrsU.js → StageHeader--oN2J-oD.js} +1 -1
- package/public/assets/StageShape-IZ80CZiJ.js +1 -0
- package/public/assets/{StageSlowness-BPRw-otN.js → StageSlowness--BRIaBc1.js} +1 -1
- package/public/assets/Straggler-Ctq0Df_o.js +1 -0
- package/public/assets/TaskFailures-Bks_gqhL.js +1 -0
- package/public/assets/{TinyTask-Bi84qU7n.js → TinyTask-BssfzGFB.js} +1 -1
- package/public/assets/{UnderBroadcast-DdQV7htE.js → UnderBroadcast-DsScrxc-.js} +1 -1
- package/public/assets/{finding-anchor-Cl6_5hfV.js → finding-anchor-BYW1oz5R.js} +1 -1
- package/public/assets/{impact-sort-CssZ6MT2.js → impact-sort-xzy0g-Vh.js} +1 -1
- package/public/assets/index-DhpRGMZc.css +2 -0
- package/public/assets/index-DwqkFUFk.js +14 -0
- package/public/assets/instrument-sans-latin-ext-wdth-normal-C5E2Gvlv.woff2 +0 -0
- package/public/assets/instrument-sans-latin-wdth-normal-BVScPF0l.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/public/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/public/assets/react-vendor-DfpuPQZf.js +9 -0
- package/public/assets/{select-CQjNhprm.js → select-C8edjYTq.js} +1 -1
- package/public/assets/{usePagedRows-Dyl6T8Ok.js → usePagedRows-CsuUFeYx.js} +1 -1
- package/public/assets/useRecentFiles-BvkKo3JN.js +11 -0
- package/public/assets/{useSortMode-Dhvit8dt.js → useSortMode-joX71BZN.js} +1 -1
- package/public/docs/404.html +3 -7
- package/public/docs/assets/{app.eYTPqTeq.js → app.CnWL6bKV.js} +1 -1
- package/public/docs/assets/chunks/@localSearchIndexroot.tE5pz54z.js +1 -0
- package/public/docs/assets/chunks/{VPLocalSearchBox.DsZrQKQR.js → VPLocalSearchBox.C0IBKeRQ.js} +1 -1
- package/public/docs/assets/chunks/{theme.DsTNE2pE.js → theme.BH-2HuRf.js} +2 -2
- package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.wj6Et6UD.js → contributor-guide_architecture_detector-contract.md.N4hhenrA.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_detector-contract_threshold-tables.md.CKjWG56Y.js → contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.js} +1 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.DfvbqEUu.js +1 -0
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.lean.js → contributor-guide_architecture_impact-estimation.md.DfvbqEUu.lean.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.BUucRc0A.lean.js → contributor-guide_architecture_impact-estimation_caveats-tuning-and-coverage.md.Cwm1jHmn.lean.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_cli-and-mcp.md.DC1YqXBp.js → contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.js} +1 -1
- package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.js} +1 -1
- package/public/docs/assets/{contributor-guide_development-setup.md.D9bu4j_c.js → contributor-guide_development-setup.md.DQdzera7.js} +1 -1
- package/public/docs/assets/instrument-sans-latin-ext-wdth-normal.C5E2Gvlv.woff2 +0 -0
- package/public/docs/assets/instrument-sans-latin-wdth-normal.BVScPF0l.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/public/docs/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/public/docs/assets/style.CyK9Ss73.css +1 -0
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.BEepbDDL.js +24 -0
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.BEepbDDL.lean.js +1 -0
- package/public/docs/assets/{user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.js → user-guide_mcp-tools_get-run-summary.md.EhaIsele.js} +1 -1
- package/public/docs/assets/user-guide_understanding-findings.md.3C7KCEZO.js +1 -0
- package/public/docs/contributor-guide/architecture/board-widgets/caching-and-memory.html +6 -10
- package/public/docs/contributor-guide/architecture/board-widgets/plan-advisor.html +4 -8
- package/public/docs/contributor-guide/architecture/board-widgets/visual-system-and-templating.html +6 -10
- package/public/docs/contributor-guide/architecture/board-widgets.html +4 -8
- package/public/docs/contributor-guide/architecture/detector-contract/plan-attribution.html +4 -8
- package/public/docs/contributor-guide/architecture/detector-contract/threshold-tables.html +6 -10
- package/public/docs/contributor-guide/architecture/detector-contract.html +6 -10
- package/public/docs/contributor-guide/architecture/drill-down/plan-graph.html +4 -8
- package/public/docs/contributor-guide/architecture/drill-down.html +4 -8
- package/public/docs/contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.html +6 -10
- package/public/docs/contributor-guide/architecture/impact-estimation/stage-union-rollup.html +4 -8
- package/public/docs/contributor-guide/architecture/impact-estimation.html +6 -10
- package/public/docs/contributor-guide/architecture/index.html +4 -8
- package/public/docs/contributor-guide/architecture/overview.html +4 -8
- package/public/docs/contributor-guide/architecture/state-and-history/history-server-intake.html +4 -8
- package/public/docs/contributor-guide/architecture/state-and-history/run-comparison-internals.html +4 -8
- package/public/docs/contributor-guide/architecture/state-and-history.html +4 -8
- package/public/docs/contributor-guide/architecture/widget-rendering/findings-tab.html +4 -8
- package/public/docs/contributor-guide/architecture/widget-rendering/investigation-routing.html +4 -8
- package/public/docs/contributor-guide/architecture/widget-rendering/list-sort-mode.html +4 -8
- package/public/docs/contributor-guide/architecture/widget-rendering/reference-section.html +4 -8
- package/public/docs/contributor-guide/architecture/widget-rendering/row-expand-contract.html +4 -8
- package/public/docs/contributor-guide/architecture/widget-rendering.html +4 -8
- package/public/docs/contributor-guide/architecture/worker-protocol/cli-and-mcp.html +6 -10
- package/public/docs/contributor-guide/architecture/worker-protocol/core-and-event-validation.html +4 -8
- package/public/docs/contributor-guide/architecture/worker-protocol/evidence-availability.html +4 -8
- package/public/docs/contributor-guide/architecture/worker-protocol/evidence-report.html +6 -10
- package/public/docs/contributor-guide/architecture/worker-protocol.html +4 -8
- package/public/docs/contributor-guide/contributing.html +4 -8
- package/public/docs/contributor-guide/development-setup.html +6 -10
- package/public/docs/contributor-guide/testing.html +4 -8
- package/public/docs/favicon.svg +2 -2
- package/public/docs/hashmap.json +1 -1
- package/public/docs/index.html +4 -8
- package/public/docs/tuning-reference/anti-patterns.html +4 -8
- package/public/docs/tuning-reference/aqe.html +4 -8
- package/public/docs/tuning-reference/bottleneck-broadcast-sizing.html +4 -8
- package/public/docs/tuning-reference/bottleneck-cold-start.html +4 -8
- package/public/docs/tuning-reference/bottleneck-duplicate-plan-subtree.html +4 -8
- package/public/docs/tuning-reference/bottleneck-failures.html +4 -8
- package/public/docs/tuning-reference/bottleneck-gc.html +4 -8
- package/public/docs/tuning-reference/bottleneck-job-failure-rate.html +4 -8
- package/public/docs/tuning-reference/bottleneck-memory-utilization.html +4 -8
- package/public/docs/tuning-reference/bottleneck-retry-waste.html +4 -8
- package/public/docs/tuning-reference/bottleneck-shuffle.html +4 -8
- package/public/docs/tuning-reference/bottleneck-skew.html +4 -8
- package/public/docs/tuning-reference/bottleneck-slow-host.html +4 -8
- package/public/docs/tuning-reference/bottleneck-small-files.html +4 -8
- package/public/docs/tuning-reference/bottleneck-spill.html +4 -8
- package/public/docs/tuning-reference/bottleneck-straggler.html +4 -8
- package/public/docs/tuning-reference/bottleneck-tiny-tasks.html +4 -8
- package/public/docs/tuning-reference/bottleneck-utilization.html +4 -8
- package/public/docs/tuning-reference/caching.html +4 -8
- package/public/docs/tuning-reference/cluster-config.html +4 -8
- package/public/docs/tuning-reference/config.html +4 -8
- package/public/docs/tuning-reference/data-formats.html +4 -8
- package/public/docs/tuning-reference/index.html +5 -9
- package/public/docs/tuning-reference/intro.html +4 -8
- package/public/docs/tuning-reference/joins.html +4 -8
- package/public/docs/tuning-reference/memory-model.html +4 -8
- package/public/docs/tuning-reference/metrics.html +4 -8
- package/public/docs/tuning-reference/partitioning.html +4 -8
- package/public/docs/tuning-reference/pyspark.html +4 -8
- package/public/docs/tuning-reference/shuffle.html +4 -8
- package/public/docs/tuning-reference/spark-architecture.html +4 -8
- package/public/docs/tuning-reference/table-formats.html +4 -8
- package/public/docs/user-guide/alternative-log-retrieval/managed-platforms.html +4 -8
- package/public/docs/user-guide/alternative-log-retrieval/ssh-bastion.html +4 -8
- package/public/docs/user-guide/alternative-log-retrieval.html +4 -8
- package/public/docs/user-guide/getting-started/ci-and-automation.html +9 -12
- package/public/docs/user-guide/getting-started/reading-the-dashboard.html +4 -8
- package/public/docs/user-guide/getting-started/regression-metric-keys.html +4 -8
- package/public/docs/user-guide/getting-started/tuning-thresholds.html +4 -8
- package/public/docs/user-guide/getting-started.html +4 -8
- package/public/docs/user-guide/mcp-tools/compare-runs.html +4 -8
- package/public/docs/user-guide/mcp-tools/diagnose-run.html +4 -8
- package/public/docs/user-guide/mcp-tools/evaluate-budgets.html +4 -8
- package/public/docs/user-guide/mcp-tools/get-finding-documentation.html +4 -8
- package/public/docs/user-guide/mcp-tools/get-finding-evidence.html +4 -8
- package/public/docs/user-guide/mcp-tools/get-reference-doc.html +4 -8
- package/public/docs/user-guide/mcp-tools/get-run-summary.html +6 -10
- package/public/docs/user-guide/mcp-tools/list-runs.html +4 -8
- package/public/docs/user-guide/mcp-tools.html +4 -8
- package/public/docs/user-guide/run-comparison/how-stages-are-matched.html +4 -8
- package/public/docs/user-guide/run-comparison.html +4 -8
- package/public/docs/user-guide/understanding-findings.html +6 -10
- package/public/export-template.html +46 -46
- package/public/favicon.svg +2 -2
- package/public/index.html +4 -7
- package/vendor-core/allocation.js +105 -20
- package/vendor-core/api-merge-writes.js +1 -0
- package/vendor-core/core-source-hash.txt +1 -1
- package/vendor-core/delta-log-paths.js +10 -1
- package/vendor-core/detectors.js +73 -38
- package/vendor-core/docs-content/detection/mem.md +1 -1
- package/vendor-core/docs-content/detection/skew.md +2 -1
- package/vendor-core/docs-content/detection/util.md +6 -2
- package/vendor-core/efficiency-model.js +22 -14
- package/vendor-core/finding-presentation.js +13 -6
- package/vendor-core/finding-types.js +21 -10
- package/vendor-core/impact-model.js +2 -2
- package/vendor-core/remediation.js +10 -5
- package/vendor-core/run-output.js +14 -3
- package/vendor-core/types.js +12 -2
- package/vendor-core/wall-clock.js +8 -0
- package/vendor-core/wasted-core-hours.js +12 -11
- package/vendor-core/write-targets.js +32 -0
- package/public/assets/AutoscalingChurn-8t3U3isq.js +0 -1
- package/public/assets/CacheUtilization-CJs9tTum.js +0 -1
- package/public/assets/ConfigAudit-B5Gbe5js.js +0 -1
- package/public/assets/Dashboard-C1hNeX49.js +0 -2
- package/public/assets/DisclosureContext-ZpE6CkHx.js +0 -1
- package/public/assets/ExecutorUtilization-D9grwi7t.js +0 -1
- package/public/assets/MemoryUtilization-BM5schZI.js +0 -1
- package/public/assets/PlanGraphRoute-DH3KJb8j.js +0 -1
- package/public/assets/RetryWaste-DOnE9vGF.js +0 -1
- package/public/assets/Skew-BmZZJ5ms.js +0 -1
- package/public/assets/SlowHost-CsJzfEqo.js +0 -1
- package/public/assets/Spill-BhNicyBy.js +0 -1
- package/public/assets/StageShape-BDxRknLp.js +0 -1
- package/public/assets/Straggler-CZCbnOWP.js +0 -1
- package/public/assets/TaskFailures-C57YcLFQ.js +0 -1
- package/public/assets/index-2h_TgUAb.css +0 -2
- package/public/assets/index-B-03mG7K.js +0 -14
- package/public/assets/react-vendor-DEdfrRTJ.js +0 -9
- package/public/assets/useRecentFiles-XZEnLlal.js +0 -11
- package/public/docs/assets/chunks/@localSearchIndexroot.u_e5bQz4.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.js +0 -1
- package/public/docs/assets/contributor-guide_architecture_impact-estimation.md.CZv_ZGOp.js +0 -1
- package/public/docs/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
- package/public/docs/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
- package/public/docs/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
- package/public/docs/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
- package/public/docs/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
- package/public/docs/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
- package/public/docs/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
- package/public/docs/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
- package/public/docs/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
- package/public/docs/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
- package/public/docs/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
- package/public/docs/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
- package/public/docs/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
- package/public/docs/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
- package/public/docs/assets/style.tmDhD3c8.css +0 -1
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.OUQqw3mI.js +0 -23
- package/public/docs/assets/user-guide_getting-started_ci-and-automation.md.OUQqw3mI.lean.js +0 -1
- package/public/docs/assets/user-guide_understanding-findings.md.C9YRruMT.js +0 -1
- /package/public/docs/assets/{contributor-guide_architecture_board-widgets_caching-and-memory.md.8bH1HnZp.lean.js → contributor-guide_architecture_board-widgets_caching-and-memory.md.CQgn1t_M.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_board-widgets_visual-system-and-templating.md.DYgUpIJB.lean.js → contributor-guide_architecture_board-widgets_visual-system-and-templating.md.wulOueeB.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_detector-contract.md.wj6Et6UD.lean.js → contributor-guide_architecture_detector-contract.md.N4hhenrA.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_detector-contract_threshold-tables.md.CKjWG56Y.lean.js → contributor-guide_architecture_detector-contract_threshold-tables.md.Dp3c40rx.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_worker-protocol_cli-and-mcp.md.DC1YqXBp.lean.js → contributor-guide_architecture_worker-protocol_cli-and-mcp.md.BX1XnFbq.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_architecture_worker-protocol_evidence-report.md.DVGZOWvW.lean.js → contributor-guide_architecture_worker-protocol_evidence-report.md.CiQl1MLb.lean.js} +0 -0
- /package/public/docs/assets/{contributor-guide_development-setup.md.D9bu4j_c.lean.js → contributor-guide_development-setup.md.DQdzera7.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_mcp-tools_get-run-summary.md.DpxZ4ZNp.lean.js → user-guide_mcp-tools_get-run-summary.md.EhaIsele.lean.js} +0 -0
- /package/public/docs/assets/{user-guide_understanding-findings.md.C9YRruMT.lean.js → user-guide_understanding-findings.md.3C7KCEZO.lean.js} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as o,o as t,c as d,a5 as c}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 r(a,e,n,i,h,l){return t(),d("div",null,[...e[0]||(e[0]=[c('<h1 id="detector-contract" tabindex="-1">Detector contract <a class="header-anchor" href="#detector-contract" aria-label="Permalink to "Detector contract""></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's <code>impactBand</code> and recommendation copy are set inside <code>detect()</code>, and so is its <code>remediation</code>: the structured <code>{kind: 'conf', 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'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't declare, or expects another scope's target fails to compile. <code>detect</code> never reads <code>this</code>. The helper freezes the entry's <code>thresholds</code> and adds <code>withThresholds(overrides?)</code>, which returns <code>detect</code> with the thresholds bound: the entry's own, or the caller'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'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's own <code>type</code>) and <code>FindingType</code> (every type an entry'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<T> }</code> so the compiler requires exactly one row per emitted type. A row holds the type'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's <code>Finding</code> member and <code>thresholds</code> as the emitting entry'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'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><Type>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><Type>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'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'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:'config'</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:'config'</code>: a config-scope detector without that flag would run through <code>analyze()</code> too. Each finding is stamped with its entry'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<FindingType, RegistryEntry></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'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'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 "no issue" 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's <code>thresholds</code>; see <a href="./detector-contract/threshold-tables.html#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 "Confidence disclosure""></a></h2><p>A <code>Finding</code> may carry <code>confidence: 'low' | 'medium' | 'high'</code> plus a <code>validationRequired</code> string. <code>RowStatusCluster</code> (<code>src/view/RowStatusCluster.tsx</code>) renders it on widget rows (the verdict's steps print their own "verify before acting" line), gated to Advanced density: a plain "<confidence> confidence" 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>'s <code>wasteModel</code> variant already disclose. None of these hardcode a single confidence value: each scales <code>'low' | 'medium' | 'high'</code> off how far the finding sits past its own detector'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 "The `fixEffort` field""></a></h2><p>Each <code>Detector</code> entry also carries <code>fixEffort: 'config' | 'code' | 'rearchitect'</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>'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>'s <code>utilization</code> and <code>memoryUtilization</code> entries, <code>efficiency-model.ts</code> and <code>wasted-core-hours.ts</code>, so the Scorecard's Unused core time and the verdict'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's capacity against its replacement's; the peak-concurrent sweeps don't.</p><h2 id="cross-detector-suppression" tabindex="-1">Cross-detector suppression <a class="header-anchor" href="#cross-detector-suppression" aria-label="Permalink to "Cross-detector suppression""></a></h2><p>An entry may name another entry's <code>type</code> in <code>suppressedBy</code>. Once every detector has run, <code>analyze()</code>'s <code>applySuppression()</code> drops each of that entry's findings on a stage where the named detector emitted a finding. It reads the unsuppressed findings, so neither the two entries' 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: 'slowHost'</code>: a stage <code>slowHost</code> already explains needs no generic "this stage is slow" finding. Suppression follows what <code>slowHost</code> actually emitted, so a run whose <code>slowHost</code> thresholds are tuned so it can'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't apply it, since no <code>scope:'config'</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 "Tuning thresholds""></a></h2><p><code>analyze()</code>'s eighth argument is <code>{ thresholds?: ThresholdOverrides }</code>: per-entry overrides keyed by entry <code>type</code>, each a partial of that entry's own <code>thresholds</code>. Omitted, every entry runs its defaults; the dashboard never passes it. The CLI's and the MCP server's <code>--thresholds <file></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's own defaults, so there is nothing to tune. A file that can't be read or parsed refuses the run the same way. The <a href="./../../user-guide/getting-started/tuning-thresholds.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>{ <name>: { 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's findings survive, so those findings carry the suppressor's tuned thresholds too, named <code><suppressor>.<name></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/evidence-report.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'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'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>',25)])])}const g=o(s,[["render",r]]);export{u as __pageData,g as default};
|
|
1
|
+
import{_ as o,o as t,c as d,a5 as c}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 r(a,e,n,i,h,l){return t(),d("div",null,[...e[0]||(e[0]=[c('<h1 id="detector-contract" tabindex="-1">Detector contract <a class="header-anchor" href="#detector-contract" aria-label="Permalink to "Detector contract""></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's <code>impactBand</code> and recommendation copy are set inside <code>detect()</code>, and so is its <code>remediation</code>: the structured <code>{kind: 'conf', 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. Entries apply together unless the recommendation words them as alternatives ("either ... or"): <code>idleCapacityFix</code> in <code>detectors.ts</code> gives an idle-capacity finding with dynamic allocation off the alternatives <code>set spark.dynamicAllocation.enabled</code> and <code>decrease spark.executor.instances</code>, and with it on <code>decrease spark.dynamicAllocation.maxExecutors</code> plus <code>decrease spark.dynamicAllocation.minExecutors</code> when the logged floor is above 0. <code>Remediation</code> is a union: <code>codeFix(hint)</code> builds the <code>{kind: 'code', hint}</code> entry for a fix no property makes, which the skew family emits when its origin is <code>other</code> or skew-join handling is already on. Code that reads <code>key</code> checks <code>kind</code> first.</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'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't declare, or expects another scope's target fails to compile. <code>detect</code> never reads <code>this</code>. The helper freezes the entry's <code>thresholds</code> and adds <code>withThresholds(overrides?)</code>, which returns <code>detect</code> with the thresholds bound: the entry's own, or the caller'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'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's own <code>type</code>) and <code>FindingType</code> (every type an entry'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<T> }</code> so the compiler requires exactly one row per emitted type. A row holds the type'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's <code>Finding</code> member and <code>thresholds</code> as the emitting entry'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'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><Type>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><Type>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'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'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:'config'</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:'config'</code>: a config-scope detector without that flag would run through <code>analyze()</code> too. Each finding is stamped with its entry'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<FindingType, RegistryEntry></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'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'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 "no issue" 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's <code>thresholds</code>; see <a href="./detector-contract/threshold-tables.html#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 "Confidence disclosure""></a></h2><p>A <code>Finding</code> may carry <code>confidence: 'low' | 'medium' | 'high'</code> plus a <code>validationRequired</code> string. <code>RowStatusCluster</code> (<code>src/view/RowStatusCluster.tsx</code>) renders it on widget rows (the verdict's steps print their own "verify before acting" line), gated to Advanced density: a plain "<confidence> confidence" 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>'s <code>wasteModel</code> variant already disclose. None of these hardcode a single confidence value: each scales <code>'low' | 'medium' | 'high'</code> off how far the finding sits past its own detector'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 "The `fixEffort` field""></a></h2><p>Each <code>Detector</code> entry also carries <code>fixEffort: 'config' | 'code' | 'rearchitect'</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>'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>'s <code>efficiency-model.ts</code>'s zero-skew floor and the cluster size the <code>utilization</code> finding reports. The idle capacity the <code>utilization</code> and <code>memoryUtilization</code> entries, <code>efficiency-model.ts</code> and <code>wasted-core-hours.ts</code> measure against is the allocation (<code>allocatedCoreMs()</code> in <code>allocation.ts</code>, cores × time each executor was alive), so the Scorecard's Unused core time and the verdict's idle figure share one capacity that never exceeds what the run held. <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's capacity against its replacement's; the peak-concurrent sweeps don't.</p><h2 id="cross-detector-suppression" tabindex="-1">Cross-detector suppression <a class="header-anchor" href="#cross-detector-suppression" aria-label="Permalink to "Cross-detector suppression""></a></h2><p>An entry may name another entry's <code>type</code> in <code>suppressedBy</code>. Once every detector has run, <code>analyze()</code>'s <code>applySuppression()</code> drops each of that entry's findings on a stage where the named detector emitted a finding. It reads the unsuppressed findings, so neither the two entries' 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: 'slowHost'</code>: a stage <code>slowHost</code> already explains needs no generic "this stage is slow" finding. Suppression follows what <code>slowHost</code> actually emitted, so a run whose <code>slowHost</code> thresholds are tuned so it can'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't apply it, since no <code>scope:'config'</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 "Tuning thresholds""></a></h2><p><code>analyze()</code>'s eighth argument is <code>{ thresholds?: ThresholdOverrides }</code>: per-entry overrides keyed by entry <code>type</code>, each a partial of that entry's own <code>thresholds</code>. Omitted, every entry runs its defaults; the dashboard never passes it. The CLI's and the MCP server's <code>--thresholds <file></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's own defaults, so there is nothing to tune. A file that can't be read or parsed refuses the run the same way. The <a href="./../../user-guide/getting-started/tuning-thresholds.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>{ <name>: { 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's findings survive, so those findings carry the suppressor's tuned thresholds too, named <code><suppressor>.<name></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/evidence-report.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'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'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>',25)])])}const g=o(s,[["render",r]]);export{u as __pageData,g as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const u=JSON.parse('{"title":"Threshold tables","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/detector-contract/threshold-tables.md","filePath":"contributor-guide/architecture/detector-contract/threshold-tables.md"}'),s={name:"contributor-guide/architecture/detector-contract/threshold-tables.md"};function r(i,e,c,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="threshold-tables" tabindex="-1">Threshold tables <a class="header-anchor" href="#threshold-tables" aria-label="Permalink to "Threshold tables""></a></h1><p>The threshold tables for every bottleneck detector, and where each finding's impact band comes from.</p><h2 id="bottleneck-thresholds" tabindex="-1">Bottleneck thresholds <a class="header-anchor" href="#bottleneck-thresholds" aria-label="Permalink to "Bottleneck thresholds""></a></h2><p>Every change to <code>packages/core/src/detectors.ts</code> should reference this table.</p><p>Every finding'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's total duration (<code>>= 2%</code> critical, <code>>= 0.5%</code> warning, else info: <code>IMPACT_FLOOR_PCT_CRIT</code>/ <code>IMPACT_FLOOR_PCT_WARN</code>, which <code>skew</code>'s <code>floorPctWarn</code> and <code>straggler</code>'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's own findings, see <a href="./../detector-contract.html#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's finding of that type didn'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>'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's a hardcoded-critical OOM/crash-risk safety signal, not a time-recovery one, so <code>detectors.ts</code>'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 "Fixed fallback only (usually wallClock-derived instead)""></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'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 > 3×</code> (<code>taskDurationP50</code> and the other P50 fields are the textbook median: the mean of the two middle values on an even count; P95 stays nearest-rank) (<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>'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's occupancy section); <code>straggler</code>'s floors use the same clip</td><td><code>warning</code></td></tr><tr><td>Shuffle read</td><td><code>shuffleReadBytes > minBytes</code> = 50 MiB, on a stage that isn'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 > 5×</code> <code>shuffleReadP50</code> <strong>and</strong> <code>shuffleReadMax > 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 > 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 < 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'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'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 > 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'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'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's duration is unknown or the stage has zero length, such as one with no completion time; a shorter stage's tail can'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 > <code>shareWarn</code> = 5%, <strong>or</strong> straggler share > <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'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's duration is unknown or the stage has zero length; coalescing can't save more than the stage's own duration, so every finding on a shorter stage would grade <code>info</code>, the tasks still tiny). Checked against each stage's measured per-task overhead (task wall time minus executor run time, the estimate'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'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'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' share of the stage'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 > <code>minFiles</code> = 100 <strong>and</strong> average file size < <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 < 10 MiB (unconditional), or < 100 MiB with the larger side > 10 GiB, or < 1 GiB with larger > 300 GiB, or < 5 GiB with larger > 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 > <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 "Spill magnitude tiers""></a></h4><p>The spill row'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 > 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 > 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 "Full threshold table (never wallClock-derived)""></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 < 0.5</code> (info, under-parallelized), on a stage lasting ≥ <code>lowParallelismFloorPct</code> = 0.5% of the run (passes when the run's duration is unknown or the stage has zero length): parallelizing can't save more than the stage'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 > 10×</code> (info, data explosion)</td><td>none</td></tr><tr><td>Stage shape: TaskStageSkew</td><td><code>taskDurationMax / stageDuration > stageShareMin</code> = 0.5 <strong>and</strong> <code>taskDurationMax / taskDurationP50 > 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 > 5% (min 10 tasks)</td><td>> 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'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'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 < 60% (info)</td><td>none</td></tr><tr><td>Autoscaling churn: short-lived executors</td><td>> 30% of executors alive under 2 min (min 5 executors)</td><td>> 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 > 50% (warning)</td><td>none</td></tr><tr><td>Memory band</td><td>peak heap / allocated > 95% too-small (warning); < 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 < 0.90</code> (info)</td><td><code>< 0.50</code> (warning)</td></tr><tr><td>Cache utilization: disk spillover</td><td><code>diskSize / (memorySize + diskSize) > 0.15</code> (info), <code>MEMORY_AND_DISK*</code> only</td><td><code>> 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'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>; <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 "Evidence fields (`stageFailed` / `retryWaste`)""></a></h4><p>Beyond a scalar <code>metric</code>/<code>value</code>, both entries' <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'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 "Evidence fields (`failures`)""></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><tag>: <loss reason></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'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>',23)])])}const m=t(s,[["render",r]]);export{u as __pageData,m as default};
|
|
1
|
+
import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const u=JSON.parse('{"title":"Threshold tables","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/detector-contract/threshold-tables.md","filePath":"contributor-guide/architecture/detector-contract/threshold-tables.md"}'),s={name:"contributor-guide/architecture/detector-contract/threshold-tables.md"};function r(i,e,c,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="threshold-tables" tabindex="-1">Threshold tables <a class="header-anchor" href="#threshold-tables" aria-label="Permalink to "Threshold tables""></a></h1><p>The threshold tables for every bottleneck detector, and where each finding's impact band comes from.</p><h2 id="bottleneck-thresholds" tabindex="-1">Bottleneck thresholds <a class="header-anchor" href="#bottleneck-thresholds" aria-label="Permalink to "Bottleneck thresholds""></a></h2><p>Every change to <code>packages/core/src/detectors.ts</code> should reference this table.</p><p>Every finding'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's total duration (<code>>= 2%</code> critical, <code>>= 0.5%</code> warning, else info: <code>IMPACT_FLOOR_PCT_CRIT</code>/ <code>IMPACT_FLOOR_PCT_WARN</code>, which <code>skew</code>'s <code>floorPctWarn</code> and <code>straggler</code>'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's own findings, see <a href="./../detector-contract.html#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's finding of that type didn'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>'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's a hardcoded-critical OOM/crash-risk safety signal, not a time-recovery one, so <code>detectors.ts</code>'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 "Fixed fallback only (usually wallClock-derived instead)""></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'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 > 3×</code> (<code>taskDurationP50</code> and the other P50 fields are the textbook median: the mean of the two middle values on an even count; P95 stays nearest-rank) (<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>'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's occupancy section); <code>straggler</code>'s floors use the same clip</td><td><code>warning</code></td></tr><tr><td>Shuffle read</td><td><code>shuffleReadBytes > minBytes</code> = 50 MiB, on a stage that isn'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 > 5×</code> <code>shuffleReadP50</code> <strong>and</strong> <code>shuffleReadMax > 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 > 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 < 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'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'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 > 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'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'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's duration is unknown or the stage has zero length, such as one with no completion time; a shorter stage's tail can'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 > <code>shareWarn</code> = 5%, <strong>or</strong> straggler share > <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'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's duration is unknown or the stage has zero length; coalescing can't save more than the stage's own duration, so every finding on a shorter stage would grade <code>info</code>, the tasks still tiny). Checked against each stage's measured per-task overhead (task wall time minus executor run time, the estimate'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'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'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' share of the stage'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 > <code>minFiles</code> = 100 <strong>and</strong> average file size < <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 < 10 MiB (unconditional), or < 100 MiB with the larger side > 10 GiB, or < 1 GiB with larger > 300 GiB, or < 5 GiB with larger > 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 > <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 "Spill magnitude tiers""></a></h4><p>The spill row'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 > 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 > 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 "Full threshold table (never wallClock-derived)""></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 < 0.5</code> (info, under-parallelized), on a stage lasting ≥ <code>lowParallelismFloorPct</code> = 0.5% of the run (passes when the run's duration is unknown or the stage has zero length): parallelizing can't save more than the stage'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 > 10×</code> (info, data explosion)</td><td>none</td></tr><tr><td>Stage shape: TaskStageSkew</td><td><code>taskDurationMax / stageDuration > stageShareMin</code> = 0.5 <strong>and</strong> <code>taskDurationMax / taskDurationP50 > 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 > 5% (min 10 tasks)</td><td>> 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'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'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>busy core time / allocated core time < 60% (info)</td><td>none</td></tr><tr><td>Autoscaling churn: short-lived executors</td><td>> 30% of executors alive under 2 min (min 5 executors)</td><td>> 60%</td></tr><tr><td>Job failure rate</td><td>≥ 30% (≥ 10% info)</td><td>≥ 50%</td></tr><tr><td>Idle cores</td><td>idle share of allocated core time > 50% (warning)</td><td>none</td></tr><tr><td>Memory band</td><td>peak heap / allocated > 95% too-small (warning); < 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 < 0.90</code> (info)</td><td><code>< 0.50</code> (warning)</td></tr><tr><td>Cache utilization: disk spillover</td><td><code>diskSize / (memorySize + diskSize) > 0.15</code> (info), <code>MEMORY_AND_DISK*</code> only</td><td><code>> 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'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>; <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 "Evidence fields (`stageFailed` / `retryWaste`)""></a></h4><p>Beyond a scalar <code>metric</code>/<code>value</code>, both entries' <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'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 "Evidence fields (`failures`)""></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><tag>: <loss reason></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'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>',23)])])}const m=t(s,[["render",r]]);export{u as __pageData,m as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{_ as o,o as t,c as a,a5 as s}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"}'),c={name:"contributor-guide/architecture/impact-estimation.md"};function d(i,e,r,n,l,h){return t(),a("div",null,[...e[0]||(e[0]=[s('<h1 id="impact-estimation" tabindex="-1">Impact estimation <a class="header-anchor" href="#impact-estimation" aria-label="Permalink to "Impact estimation""></a></h1><p>Every finding covered by this section carries an optional <code>impactEstimate: {basis, wallClock, estimateMethod, rawWaste?, coreTimeMs?, idleCoreTimeMs?}</code> (<code>packages/core/src/types.ts</code>), attached by its <code>DETECTORS</code> entry'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's runtime floor reads. <code>estimateMethod</code> ('measured' | 'modeled' | 'none') 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>'none'</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>'s byte-dimension multiDim shapes, <code>gc</code>'s low-GC direction, <code>cacheUtilization</code>'s <code>storageUnobserved</code> caveat, <code>duplicatePlanSubtree</code> when its repeats differ or map to no stage): it's distinct from <code>'measured'</code>/<code>'modeled'</code>, which both attach a real (if approximate) formula. <code>basis</code> is one of:</p><ul><li><code>'serial'</code>: the tied stage ran (effectively) alone; <code>wallClock</code> is a near-point estimate, <code>low === high</code>.</li><li><code>'contended'</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>'resourceOnly'</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's real signal survives in <code>rawWaste</code>.</li><li><code>'informational'</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: 'none'</code> findings, which have no formula at all), holding the formula's pre-clip magnitude in the formula'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'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's removed task time (<code>tailClaimImpact()</code> sets it from the claim's <code>removedCoreWorkMs</code>), or the raw figure of a type in <code>MEASURED_CORE_TIME_FIGURE</code>: gc'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'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'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>A modeled figure on a serial basis carries no core time either. <code>partitionSizing</code>'s <code>lowShuffleParallelism</code> reports <code>rawWaste</code> in <code>ms</code>: stage wall-clock that more partitions shorten, not task work they remove, so it is ranked on <code>wallClock</code>.</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>'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 "of idle core capacity" instead of "of core time" on every surface. <code>memoryUtilization</code>'s <code>idleCores</code> counts <code>mbSeconds</code>, which has no core time either. <code>utilization</code> also reports the idle figure as <code>idleCoreTimeMs</code> (<code>{low, high}</code> core-milliseconds, <code>low === high</code>): <code>allocatedCoreMs - busy core-ms</code>, which is <code>rawWaste</code> in core-ms, set by its <code>estimate()</code> and never read by <code>coreTimeFor()</code>. The capacity is <code>allocatedCoreMs()</code> in <code>packages/core/src/allocation.ts</code>, the figure behind <code>metrics.allocation.coreHours</code>. <code>idleCores</code> is the memory view of the same condition and has none. A stage'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 "Occupancy-weighted attribution""></a></h2><p><code>packages/core/src/occupancy.ts</code> sweeps every stage's observed <code>[submittedAt, completedAt)</code> window and splits each instant'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's whole window: this codebase has no per-task timestamps outside the parser worker to do better). Summing a stage'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) <= 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'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'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'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>'s <code>shortensLongestTask</code> option, passed by <code>detectors.ts</code>'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>'s more-partitions estimate and <code>partitionSizing</code>'s <code>lowShuffleParallelism</code> pass it too, since splitting partitions splits the longest task). Their claim shortens the stage's longest task itself, so <code>taskDurationMax</code> can'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'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; for <code>lowShuffleParallelism</code> it is <code>taskDurationMax × taskCount / targetTaskCount</code>, the longest task after an even split into the target task count, a modeled figure). Counting the stragglers' own run time as work the stage can'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's <code>tailReplayRecoveryMs</code>: <code>finalizeStage</code> replays the stage'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>'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>'s <code>computePeakConcurrentCores(app, executorsAdded, executorsRemoved)</code> (the same peak concurrent cores <code>efficiency-model.ts</code>'s floor, <code>wasted-core-hours.ts</code> and the <code>utilization</code> finding report as the cluster size), not <code>computeTotalCores</code>, which only <code>scaling-sim.ts</code> uses. <code>computeTotalCores</code> sums every <code>ExecutorAdded</code> event'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's claimed wall-clock. <code>computePeakConcurrentCores</code> instead sweeps add/remove events by timestamp and tracks the running total's peak, so a churned-through executor's cores are never double-counted against its replacement's. Same-timestamp events tie-break by delta ascending, so a removal applies before a same-instant replacement'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) >= 0.999</code>: <code>basis: 'serial'</code>, <code>low = high = wasteMs_clipped(S)</code>.</li><li><code>gate(S) < 0.999</code>: <code>basis: 'contended'</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's own estimate and caps the joint total at the union of just that finding'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'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't derived from that numeric equality: a multi-stage finding gets <code>basis: 'serial'</code> only when every one of its per-stage estimates was itself <code>'serial'</code>; otherwise <code>'contended'</code>.</p><p>Regression guard: a finding whose stage (or, for a multi-stage finding, every one of its stages) ran alone (<code>gate >= 0.95</code>, deliberately looser than the <code>0.999</code> "serial" 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 > 0</code>) must never report <code>wallClock.high === 0</code> or <code>basis: 'informational'/'resourceOnly'</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's blind to <code>coldStart</code>, the purely informational (<code>estimateMethod: 'none'</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>',22)])])}const m=o(c,[["render",d]]);export{g as __pageData,m as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as o,o as t,c as a,a5 as s}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"}'),c={name:"contributor-guide/architecture/impact-estimation.md"};function d(i,e,r,n,l,h){return t(),a("div",null,[...e[0]||(e[0]=[s("",
|
|
1
|
+
import{_ as o,o as t,c as a,a5 as s}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"}'),c={name:"contributor-guide/architecture/impact-estimation.md"};function d(i,e,r,n,l,h){return t(),a("div",null,[...e[0]||(e[0]=[s("",22)])])}const m=o(c,[["render",d]]);export{g as __pageData,m as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Caveats, tuning and coverage","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md","filePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"}'),s={name:"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"};function c(r,e,i,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="caveats-tuning-and-coverage" tabindex="-1">Caveats, tuning and coverage <a class="header-anchor" href="#caveats-tuning-and-coverage" aria-label="Permalink to "Caveats, tuning and coverage""></a></h1><p>Spot-checks for each estimate formula, the overlap caveat, the effect of tuned thresholds and which finding types have an estimate.</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 "Per-formula spot-checks""></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's ≈3.9% of the stage's 56.3s wall-clock duration, matching the finding's own reported <code>gcPct</code> (3.9%) exactly, as the formula guarantees by construction. Under the occupancy model this stage's <code>gate</code> is <code>0.041</code> (0.04145044590332269 exactly): <code>basis: 'contended'</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's 763,776ms duration. The tasks' 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'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 "Overlap caveat: skew / straggler""></a></h2><p><code>skew</code> and <code>straggler</code> both claim the stage's <code>tailReplayRecoveryMs</code> (see <a href="./../impact-estimation.html#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="./../impact-estimation.html#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's excess as recoverable instead of the near-zero room <code>ceiling >= taskDurationMax</code> would leave.</p><p><code>analyzer.ts</code>'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 "this overlaps with the X finding on this stage" sentence to both findings' <code>validationRequired</code> text (rather than suppressing either, so neither finding'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'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'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>'s <code>taskStageSkew</code> rule doesn'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'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 "Tuned thresholds""></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'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'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 "Per-finding-type coverage""></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's inputs are recorded per-stage fields or an assumed constant like a throughput figure); <code>cost-only</code> always reports <code>basis: 'resourceOnly'</code>, <code>wallClock: null</code> but carries its real signal in <code>rawWaste</code>; <code>informational-only</code> reports <code>basis: 'informational'</code>, <code>wallClock: null</code> with no <code>rawWaste</code> at all, since there'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: 'serial'</code> unconditionally (a pre-first-task gap can'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: 'low'</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'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: 'measured'</code>, real per-stage fields, no assumed constant: <code>'lowParallelism'</code> → <code>rawWaste</code> in <code>coreMs</code> (idle cores × stage duration); <code>'dataExplosion'</code> → <code>rawWaste</code> in <code>bytes</code> (<code>outputBytes − inputBytes</code>); <code>'taskStageSkew'</code> → <code>rawWaste</code> in <code>coreMs</code> (<code>max(0, min(totalCores, taskCount) − 1) × (taskDurationMax − taskDurationP50)</code>, the cores idle during the straggler's tail at achieved concurrency; <code>totalCores</code> here is the finding's own figure: the executors that ran the stage × <code>spark.executor.cores</code>, not the run'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'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: 'none'</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' share of that stage × the redundant fraction <code>(occurrences − 1) / occurrences</code>, summed and capped at the union of those stages' spans. A stage shared with other operators (the consuming join, the other join side) contributes only its share, so sibling groups can'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' 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'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'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'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's recommendation) could recover: the stage's task-active time (<code>taskActiveMs</code>, the union of its tasks' 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'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'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'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's actual count, × the stage's own measured per-task overhead (summed task wall time from <code>executorStats</code> minus <code>executorRunTime</code>, over <code>taskCount</code>), ÷ the stage'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's share is gate-clipped, and the sum is capped at the stages' 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/plan-attribution.html#stage-id-attribution-for-plan-advisor-findings">Stage-ID attribution for Plan Advisor findings</a>); falls back to the whole execution'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>'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'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>'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'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: 'wasteModel'</code> passes through its own <code>wastedMBSeconds</code>; <code>'idleCores'</code> uses <code>idleRateFraction × allocatedMB × peakExecutors × appDurationSeconds</code>; <code>'memoryBand'</code> with <code>rule: 'heapOverProvisioned'</code> uses <code>(allocatedBytes − heap) in MB × appDurationSeconds</code>. <code>'memoryBand'</code> with <code>rule: 'heapNearCapacity'</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' bytes are extrapolated from the cached partitions' own average size (<code>memorySize + diskSize</code>, over <code>numCachedPartitions</code>), plus <code>diskSize</code> again for the already-cached-but-on-disk partitions' 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>',13)])])}const p=t(s,[["render",c]]);export{g as __pageData,p as default};
|
|
1
|
+
import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Caveats, tuning and coverage","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md","filePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"}'),s={name:"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"};function c(r,e,i,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a('<h1 id="caveats-tuning-and-coverage" tabindex="-1">Caveats, tuning and coverage <a class="header-anchor" href="#caveats-tuning-and-coverage" aria-label="Permalink to "Caveats, tuning and coverage""></a></h1><p>Spot-checks for each estimate formula, the overlap caveat, the effect of tuned thresholds and which finding types have an estimate.</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 "Per-formula spot-checks""></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's ≈3.9% of the stage's 56.3s wall-clock duration, matching the finding's own reported <code>gcPct</code> (3.9%) exactly, as the formula guarantees by construction. Under the occupancy model this stage's <code>gate</code> is <code>0.041</code> (0.04145044590332269 exactly): <code>basis: 'contended'</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's 763,776ms duration. The tasks' 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'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 "Overlap caveat: skew / straggler""></a></h2><p><code>skew</code> and <code>straggler</code> both claim the stage's <code>tailReplayRecoveryMs</code> (see <a href="./../impact-estimation.html#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="./../impact-estimation.html#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's excess as recoverable instead of the near-zero room <code>ceiling >= taskDurationMax</code> would leave.</p><p><code>analyzer.ts</code>'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 "this overlaps with the X finding on this stage" sentence to both findings' <code>validationRequired</code> text (rather than suppressing either, so neither finding'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'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'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>'s <code>taskStageSkew</code> rule doesn'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'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 "Tuned thresholds""></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'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'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 "Per-finding-type coverage""></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's inputs are recorded per-stage fields or an assumed constant like a throughput figure); <code>cost-only</code> always reports <code>basis: 'resourceOnly'</code>, <code>wallClock: null</code> but carries its real signal in <code>rawWaste</code>; <code>informational-only</code> reports <code>basis: 'informational'</code>, <code>wallClock: null</code> with no <code>rawWaste</code> at all, since there'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: 'serial'</code> unconditionally (a pre-first-task gap can'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: 'low'</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'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: 'measured'</code>, real per-stage fields, no assumed constant: <code>'lowParallelism'</code> → <code>rawWaste</code> in <code>coreMs</code> (idle cores × stage duration); <code>'dataExplosion'</code> → <code>rawWaste</code> in <code>bytes</code> (<code>outputBytes − inputBytes</code>); <code>'taskStageSkew'</code> → <code>rawWaste</code> in <code>coreMs</code> (<code>max(0, min(totalCores, taskCount) − 1) × (taskDurationMax − taskDurationP50)</code>, the cores idle during the straggler's tail at achieved concurrency; <code>totalCores</code> here is the finding's own figure: the executors that ran the stage × <code>spark.executor.cores</code>, not the run'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'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: 'none'</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' share of that stage × the redundant fraction <code>(occurrences − 1) / occurrences</code>, summed and capped at the union of those stages' spans. A stage shared with other operators (the consuming join, the other join side) contributes only its share, so sibling groups can'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' 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'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'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'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's recommendation) could recover: the stage's task-active time (<code>taskActiveMs</code>, the union of its tasks' 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'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'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'd add to fix it. The claim shortens the longest task, so it is capped at the reduction of that task (<code>taskDurationMax − longestTaskAfterFixMs</code>, with <code>longestTaskAfterFixMs = taskDurationMax × taskCount / targetTaskCount</code>, the longest task after an even split: a modeled figure, optimistic when <code>shufflePartitionSkew</code> fires on the same stage) and gate-clipped with the post-fix floor (<code>shortensLongestTask</code>). <code>coreTimeMs</code> is null: the figure is wall-clock, not removed task time</td></tr><tr><td><code>tinyTask</code></td><td>stage</td><td>measured / modeled</td><td>excess task count over 10% of the stage's actual count, × the stage's own measured per-task overhead (summed task wall time from <code>executorStats</code> minus <code>executorRunTime</code>, over <code>taskCount</code>), ÷ the stage'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's share is gate-clipped, and the sum is capped at the stages' 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/plan-attribution.html#stage-id-attribution-for-plan-advisor-findings">Stage-ID attribution for Plan Advisor findings</a>); falls back to the whole execution'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>'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'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>'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'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: 'wasteModel'</code> passes through its own <code>wastedMBSeconds</code>; <code>'idleCores'</code> uses <code>idleRateFraction × allocatedMBSeconds</code>, with <code>idleRateFraction</code> measured against the same allocated core time as <code>utilization</code> and <code>allocatedMBSeconds</code> the run's allocated memory-seconds from <code>computeAllocation()</code> (executor heap plus overhead over each executor's alive time; Spark's defaults when the memory properties are not logged); <code>'memoryBand'</code> with <code>rule: 'heapOverProvisioned'</code> uses <code>(allocatedBytes − heap) in MB × appDurationSeconds</code>. <code>'memoryBand'</code> with <code>rule: 'heapNearCapacity'</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) × allocatedCoreMs / 3.6e6</code>, where <code>allocatedCoreMs</code> is cores × time alive over every executor (<code>allocatedCoreMs()</code>, the figure behind <code>metrics.allocation.coreHours</code>), so it never exceeds the allocation. The same figure in core-milliseconds is <code>idleCoreTimeMs</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' bytes are extrapolated from the cached partitions' own average size (<code>memorySize + diskSize</code>, over <code>numCachedPartitions</code>), plus <code>diskSize</code> again for the already-cached-but-on-disk partitions' 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>',13)])])}const m=t(s,[["render",c]]);export{g as __pageData,m as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Caveats, tuning and coverage","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md","filePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"}'),s={name:"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"};function c(r,e,i,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a("",13)])])}const
|
|
1
|
+
import{_ as t,o,c as d,a5 as a}from"./chunks/framework.DSg0KOwT.js";const g=JSON.parse('{"title":"Caveats, tuning and coverage","description":"","frontmatter":{},"headers":[],"relativePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md","filePath":"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"}'),s={name:"contributor-guide/architecture/impact-estimation/caveats-tuning-and-coverage.md"};function c(r,e,i,n,l,h){return o(),d("div",null,[...e[0]||(e[0]=[a("",13)])])}const m=t(s,[["render",c]]);export{g as __pageData,m as default};
|