diff --git a/Cargo.lock b/Cargo.lock index b7dfffce..34f106da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,6 +307,7 @@ name = "asap-aware-mapping" version = "0.1.0" dependencies = [ "asap-types", + "serde", "serde_json", "thiserror", ] @@ -355,6 +356,7 @@ dependencies = [ "asap-frontend-promql", "asap-frontend-sql", "asap-types", + "serde_json", "tokio", ] diff --git a/crates/asap-aware-mapping/Cargo.toml b/crates/asap-aware-mapping/Cargo.toml index f7283834..352ed1cf 100644 --- a/crates/asap-aware-mapping/Cargo.toml +++ b/crates/asap-aware-mapping/Cargo.toml @@ -10,4 +10,5 @@ edition = "2021" [dependencies] asap-types = { path = "../types" } thiserror = "2" +serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/asap-aware-mapping/src/lib.rs b/crates/asap-aware-mapping/src/lib.rs index ed1570e4..2919486d 100644 --- a/crates/asap-aware-mapping/src/lib.rs +++ b/crates/asap-aware-mapping/src/lib.rs @@ -190,6 +190,7 @@ pub mod recurrence; pub mod replacement; pub mod rewrite; pub mod rollup; +pub mod summary_maintenance_dag_export; pub mod summary_maintenance_lifecycle; pub mod topk_reuse; @@ -218,6 +219,10 @@ pub use replacement::{ MAX_SEARCH_ITERATIONS, }; pub use rewrite::AvgToSumOverCountStrategy; +pub use summary_maintenance_dag_export::{ + export_summary_maintenance_plan, SummaryMaintenanceDagExport, + SummaryMaintenanceDeploymentExport, SummaryMaintenanceLifecycleAlternativeExport, +}; pub use summary_maintenance_lifecycle::{ global_selection_with_summary_maintenance_lifecycles, materialize_with_summary_maintenance_lifecycles, plan_summary_maintenance_lifecycles, diff --git a/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs b/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs new file mode 100644 index 00000000..dac6e1d0 --- /dev/null +++ b/crates/asap-aware-mapping/src/summary_maintenance_dag_export.rs @@ -0,0 +1,277 @@ +//! Serializable DAG export for a materialized summary-maintenance plan. +//! +//! `asap-types::dag_export` owns the crate-neutral post-ASAP graph shape. This +//! adapter lives in the mapping layer, where summary-maintenance lifecycle +//! alternatives and their typed rejection reasons are available, and emits +//! both views together. + +use std::collections::HashMap; +use std::rc::Rc; + +use serde::Serialize; + +use asap_types::dag_export::{self, SummaryDagGraph}; +use asap_types::post_asap::{ + EvaluationSchedule, OutputRepresentation, SummaryExpr, SummaryMaintenanceLifecycle, + SummaryMaintenanceLifecycleGuarantee, SummaryMaintenanceMode, SummaryNode, +}; + +use crate::summary_maintenance_lifecycle::{ + SummaryMaintenanceLifecyclePlan, SummaryMaintenanceLifecycleRejection, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct SummaryMaintenanceDagExport { + pub graph: SummaryDagGraph, + pub deployments: Vec, + pub horizon_seconds: Option, + pub evaluation_rate_per_second: Option, + pub update_rate_per_second: Option, + pub expected_reads: Option, + pub selected_raw_recompute: bool, + pub summary_total_cost: Option, + pub raw_recompute_total_cost: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SummaryMaintenanceDeploymentExport { + pub summary_index: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub selected: Option, + pub alternatives: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SummaryMaintenanceLifecycleAlternativeExport { + pub lifecycle: SummaryMaintenanceLifecycleExport, + pub total_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rejection: Option, + pub assumptions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SummaryMaintenanceLifecycleGuaranteeExport { + pub lifecycle: SummaryMaintenanceLifecycleExport, + pub maintenance_mode: SummaryMaintenanceModeExport, + pub evaluation_schedule: EvaluationScheduleExport, + pub output_representation: OutputRepresentationExport, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SummaryMaintenanceModeExport { + DirectBuild, + Incremental, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SummaryMaintenanceLifecycleExport { + Ephemeral, + Prepared { + activate_at_ms: u64, + retire_at_ms: u64, + }, + Shared { + retention_ms: u64, + }, + ContinuouslyMaintained, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EvaluationScheduleExport { + OneShot, + PerUpdate, + OnRead, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputRepresentationExport { + PlainRows, + SummaryState, + FinalizedValue, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SummaryMaintenanceLifecycleRejectionExport { + UnsupportedByRuntime, + RequiresPredictableOneTimeQuery, + RequiresMultipleReads, + RequiresHorizon, + RequiresContinuousData, + MissingOrStaleIngestionRate, + SummaryDoesNotSupportIncrementalUpdates, + SummaryDoesNotSupportDeletion, + MissingCostEvidence, +} + +pub fn export_summary_maintenance_plan( + plan: &SummaryMaintenanceLifecyclePlan, +) -> SummaryMaintenanceDagExport { + let deployments: Vec<_> = plan + .deployments + .iter() + .map(|deployment| SummaryMaintenanceDeploymentExport { + summary_index: deployment.summary_index, + selected: deployment + .summary_maintenance_lifecycle_guarantee + .as_ref() + .map(export_guarantee), + alternatives: deployment + .alternatives + .iter() + .map(|alternative| SummaryMaintenanceLifecycleAlternativeExport { + lifecycle: export_lifecycle(&alternative.summary_maintenance_lifecycle), + total_cost: alternative.total_cost.map(|cost| cost.0), + rejection: alternative.rejection.as_ref().map(export_rejection), + assumptions: alternative.assumptions.clone(), + }) + .collect(), + }) + .collect(); + let mut graph = dag_export::export_summary(&plan.root); + let deployment_by_summary: HashMap<_, _> = plan + .deployments + .iter() + .zip(&deployments) + .map(|(deployment, export)| (Rc::as_ptr(&deployment.summary), export)) + .collect(); + let mut next_node_id = 0; + annotate_lifecycle_deployments( + &plan.root, + &mut graph, + &deployment_by_summary, + &mut next_node_id, + ); + + SummaryMaintenanceDagExport { + graph, + deployments, + horizon_seconds: plan.horizon.map(|horizon| horizon.0), + evaluation_rate_per_second: plan.evaluation_rate.map(|rate| rate.0), + update_rate_per_second: plan.update_rate.map(|rate| rate.0), + expected_reads: plan.expected_reads, + selected_raw_recompute: plan.selected_raw_recompute, + summary_total_cost: plan.summary_total_cost.map(|cost| cost.0), + raw_recompute_total_cost: plan.raw_recompute_total_cost.map(|cost| cost.0), + } +} + +/// Walk in the same post-order as `dag_export::export_summary` and attach a +/// deployment directly to every flattened occurrence of its `SummaryAgg`. +/// This makes the decision visible to graph consumers without asking them to +/// reconstruct pointer identity from `summary_index` or graph position. +fn annotate_lifecycle_deployments( + node: &SummaryNode, + graph: &mut SummaryDagGraph, + deployments: &HashMap<*const SummaryNode, &SummaryMaintenanceDeploymentExport>, + next_node_id: &mut usize, +) { + if !matches!(node.expr, SummaryExpr::KeepPreAsap(_)) { + for child in summary_children(&node.expr) { + annotate_lifecycle_deployments(child, graph, deployments, next_node_id); + } + } + let graph_node = &mut graph.nodes[*next_node_id]; + if let Some(deployment) = deployments.get(&(node as *const SummaryNode)) { + graph_node.detail["summary_maintenance"] = + serde_json::to_value(deployment).expect("lifecycle export is serializable"); + } + *next_node_id += 1; +} + +fn summary_children(expr: &SummaryExpr) -> Vec<&Rc> { + match expr { + SummaryExpr::KeepPreAsap(_) => vec![], + SummaryExpr::SummaryAgg { child, .. } => vec![child], + SummaryExpr::SummaryJoin { outer, inner, .. } + | SummaryExpr::SummarySubtract { + left: outer, + right: inner, + } => vec![outer, inner], + SummaryExpr::SummaryDelete { summary_input, .. } + | SummaryExpr::SummaryEstimate { summary_input, .. } => vec![summary_input], + SummaryExpr::SummaryMerge { children } => children.iter().collect(), + } +} + +fn export_guarantee( + guarantee: &SummaryMaintenanceLifecycleGuarantee, +) -> SummaryMaintenanceLifecycleGuaranteeExport { + SummaryMaintenanceLifecycleGuaranteeExport { + lifecycle: export_lifecycle(&guarantee.summary_maintenance_lifecycle), + maintenance_mode: match guarantee.summary_maintenance_mode { + SummaryMaintenanceMode::DirectBuild => SummaryMaintenanceModeExport::DirectBuild, + SummaryMaintenanceMode::Incremental => SummaryMaintenanceModeExport::Incremental, + }, + evaluation_schedule: match guarantee.evaluation_schedule { + EvaluationSchedule::OneShot => EvaluationScheduleExport::OneShot, + EvaluationSchedule::PerUpdate => EvaluationScheduleExport::PerUpdate, + EvaluationSchedule::OnRead => EvaluationScheduleExport::OnRead, + }, + output_representation: match guarantee.output_representation { + OutputRepresentation::PlainRows => OutputRepresentationExport::PlainRows, + OutputRepresentation::SummaryState => OutputRepresentationExport::SummaryState, + OutputRepresentation::FinalizedValue => OutputRepresentationExport::FinalizedValue, + }, + } +} + +fn export_lifecycle(lifecycle: &SummaryMaintenanceLifecycle) -> SummaryMaintenanceLifecycleExport { + match lifecycle { + SummaryMaintenanceLifecycle::Ephemeral => SummaryMaintenanceLifecycleExport::Ephemeral, + SummaryMaintenanceLifecycle::Prepared { + activate_at, + retire_at, + } => SummaryMaintenanceLifecycleExport::Prepared { + activate_at_ms: activate_at.0, + retire_at_ms: retire_at.0, + }, + SummaryMaintenanceLifecycle::Shared { retention } => { + SummaryMaintenanceLifecycleExport::Shared { + retention_ms: retention.0, + } + } + SummaryMaintenanceLifecycle::ContinuouslyMaintained => { + SummaryMaintenanceLifecycleExport::ContinuouslyMaintained + } + } +} + +fn export_rejection( + rejection: &SummaryMaintenanceLifecycleRejection, +) -> SummaryMaintenanceLifecycleRejectionExport { + match rejection { + SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime => { + SummaryMaintenanceLifecycleRejectionExport::UnsupportedByRuntime + } + SummaryMaintenanceLifecycleRejection::RequiresPredictableOneTimeQuery => { + SummaryMaintenanceLifecycleRejectionExport::RequiresPredictableOneTimeQuery + } + SummaryMaintenanceLifecycleRejection::RequiresMultipleReads => { + SummaryMaintenanceLifecycleRejectionExport::RequiresMultipleReads + } + SummaryMaintenanceLifecycleRejection::RequiresHorizon => { + SummaryMaintenanceLifecycleRejectionExport::RequiresHorizon + } + SummaryMaintenanceLifecycleRejection::RequiresContinuousData => { + SummaryMaintenanceLifecycleRejectionExport::RequiresContinuousData + } + SummaryMaintenanceLifecycleRejection::MissingOrStaleIngestionRate => { + SummaryMaintenanceLifecycleRejectionExport::MissingOrStaleIngestionRate + } + SummaryMaintenanceLifecycleRejection::SummaryDoesNotSupportIncrementalUpdates => { + SummaryMaintenanceLifecycleRejectionExport::SummaryDoesNotSupportIncrementalUpdates + } + SummaryMaintenanceLifecycleRejection::SummaryDoesNotSupportDeletion => { + SummaryMaintenanceLifecycleRejectionExport::SummaryDoesNotSupportDeletion + } + SummaryMaintenanceLifecycleRejection::MissingCostEvidence => { + SummaryMaintenanceLifecycleRejectionExport::MissingCostEvidence + } + } +} diff --git a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs index 70204c8f..87412cf9 100644 --- a/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs +++ b/crates/asap-aware-mapping/src/summary_maintenance_lifecycle.rs @@ -1829,6 +1829,13 @@ mod tests { assert_eq!(plan.summary_total_cost, None); assert!(plan.deployments.is_empty()); assert!(matches!(plan.root.expr, SummaryExpr::KeepPreAsap(_))); + + let exported = + crate::summary_maintenance_dag_export::export_summary_maintenance_plan(&plan); + assert!(exported.selected_raw_recompute); + assert_eq!(exported.raw_recompute_total_cost, Some(1.0)); + assert_eq!(exported.summary_total_cost, None); + assert!(exported.deployments.is_empty()); } #[test] diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 11873eff..878d8236 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -10,4 +10,5 @@ asap-frontend-sql = { path = "../frontend-sql" } asap-aware-mapping = { path = "../asap-aware-mapping" } [dev-dependencies] +serde_json = "1" tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread"] } diff --git a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs index 85648fcf..6a2ee074 100644 --- a/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs +++ b/crates/integration-tests/tests/summary_maintenance_lifecycle_e2e.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use asap_aware_mapping::cost_model::Cost; use asap_aware_mapping::CostRate; use asap_aware_mapping::{ - global_selection_with_summary_maintenance_lifecycles, + export_summary_maintenance_plan, global_selection_with_summary_maintenance_lifecycles, materialize_with_summary_maintenance_lifecycles, search_workload_with, CostModel, Horizon, SummaryMaintenanceCapabilities, SummaryMaintenanceLifecycleCapabilities, SummaryMaintenanceLifecycleCostInputs, SummaryMaintenanceLifecycleRejection, WorkloadDemand, @@ -176,4 +176,36 @@ fn promql_dashboard_materializes_continuous_summary_with_explained_rejections() ) && alternative.rejection == Some(SummaryMaintenanceLifecycleRejection::UnsupportedByRuntime) })); + + let exported = serde_json::to_value(export_summary_maintenance_plan(&plan)).unwrap(); + assert_eq!( + exported["deployments"][0]["selected"]["lifecycle"]["kind"], + "continuously_maintained" + ); + assert_eq!( + exported["deployments"][0]["selected"]["maintenance_mode"], + "incremental" + ); + let alternatives = exported["deployments"][0]["alternatives"] + .as_array() + .expect("exported lifecycle alternatives"); + assert!(alternatives.iter().any(|alternative| { + alternative["lifecycle"]["kind"] == "prepared" + && alternative["rejection"] == "requires_predictable_one_time_query" + })); + assert!(alternatives.iter().any(|alternative| { + alternative["lifecycle"]["kind"] == "shared" + && alternative["rejection"] == "unsupported_by_runtime" + })); + assert!(exported["graph"]["nodes"].as_array().is_some()); + let summary_node = exported["graph"]["nodes"] + .as_array() + .unwrap() + .iter() + .find(|node| node["kind"] == "SummaryAgg") + .expect("exported SummaryAgg node"); + assert_eq!( + summary_node["detail"]["summary_maintenance"]["selected"]["lifecycle"]["kind"], + "continuously_maintained" + ); } diff --git a/tools/dag-viewer/README.md b/tools/dag-viewer/README.md index 1c3fd694..ba8bad70 100644 --- a/tools/dag-viewer/README.md +++ b/tools/dag-viewer/README.md @@ -59,6 +59,15 @@ cargo run -p asap-devtools --bin dag_export -- \ Load the JSON with the page's file picker. A post-ASAP visualization requires `--post-asap`; ordinary exports intentionally omit `post_graph`. +The viewer also accepts the JSON produced by +`export_summary_maintenance_plan`. It renders the materialized summary DAG as +a single lifecycle-plan lane. Selecting a `SummaryAgg` shows the chosen +lifecycle and maintenance mode together with every alternative's cost, +assumptions, and rejection reason. The selected-node panel also shows the +plan-level summary-versus-raw decision, costs, horizon, expected reads, and +evaluation/update rates. Raw-recomputation plans retain that decision summary +even though they have no deployed `SummaryAgg` to annotate. + ## Standalone HTML ```sh diff --git a/tools/dag-viewer/lifecycle-summary-maintenance.png b/tools/dag-viewer/lifecycle-summary-maintenance.png new file mode 100644 index 00000000..e873ffd9 Binary files /dev/null and b/tools/dag-viewer/lifecycle-summary-maintenance.png differ diff --git a/tools/dag-viewer/render.py b/tools/dag-viewer/render.py index 2059597f..8fa8b67c 100755 --- a/tools/dag-viewer/render.py +++ b/tools/dag-viewer/render.py @@ -260,7 +260,25 @@ def load_workload(paths: list[Path]) -> dict: seen_names = set() for path in paths: data = json.loads(path.read_text()) - for q in data.get("queries", []): + incoming = data.get("queries", []) + if not incoming and isinstance(data.get("graph"), dict) and isinstance(data.get("deployments"), list): + incoming = [{ + "name": path.stem or "Summary maintenance plan", + "graph": data["graph"], + "post_graph": data["graph"], + "lifecycle_plan": True, + "lifecycle_summary": { + "selected_raw_recompute": data.get("selected_raw_recompute", False), + "summary_total_cost": data.get("summary_total_cost"), + "raw_recompute_total_cost": data.get("raw_recompute_total_cost"), + "horizon_seconds": data.get("horizon_seconds"), + "evaluation_rate_per_second": data.get("evaluation_rate_per_second"), + "update_rate_per_second": data.get("update_rate_per_second"), + "expected_reads": data.get("expected_reads"), + "deployment_count": len(data["deployments"]), + }, + }] + for q in incoming: name = q["name"] if name in seen_names: name = f'{q["name"]} ({path.name})' diff --git a/tools/dag-viewer/test_render.py b/tools/dag-viewer/test_render.py index 6966287e..ba353d1f 100644 --- a/tools/dag-viewer/test_render.py +++ b/tools/dag-viewer/test_render.py @@ -55,6 +55,43 @@ def named_graph(name: str, source: str = "SELECT 1") -> dict: class LoadWorkloadTests(unittest.TestCase): + def test_loads_summary_maintenance_export_as_a_lifecycle_plan(self): + graph = named_graph("unused")["graph"] + summary = { + "selected_raw_recompute": True, + "summary_total_cost": None, + "raw_recompute_total_cost": 7.5, + "horizon_seconds": 60.0, + "evaluation_rate_per_second": 2.0, + "update_rate_per_second": 3.0, + "expected_reads": 120.0, + } + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "lifecycle.json" + path.write_text(json.dumps({"graph": graph, "deployments": [], **summary})) + workload = load_workload([path]) + + query = workload["queries"][0] + self.assertEqual(query["name"], "lifecycle") + self.assertTrue(query["lifecycle_plan"]) + self.assertEqual(query["post_graph"], graph) + self.assertEqual( + query["lifecycle_summary"], + {**summary, "deployment_count": 0}, + ) + + def test_preserves_summary_plan_deployment_count(self): + graph = named_graph("unused")["graph"] + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "lifecycle.json" + path.write_text(json.dumps({"graph": graph, "deployments": [{}, {}]})) + workload = load_workload([path]) + + self.assertEqual( + workload["queries"][0]["lifecycle_summary"]["deployment_count"], + 2, + ) + def test_merges_queries_across_files_in_order(self): with tempfile.TemporaryDirectory() as d: f1 = Path(d) / "a.json" diff --git a/tools/dag-viewer/viewer.js b/tools/dag-viewer/viewer.js index feca4f8c..19d19153 100644 --- a/tools/dag-viewer/viewer.js +++ b/tools/dag-viewer/viewer.js @@ -93,13 +93,19 @@ function loadFiles(fileList) { reader.onload = () => { try { const parsed = JSON.parse(reader.result); - const incoming = parsed.queries || []; + const incoming = parsed.queries || (parsed.graph && parsed.deployments ? [{ + name: file.name.replace(/\.json$/i, '') || 'Summary maintenance plan', + graph: parsed.graph, + post_graph: parsed.graph, + lifecycle_plan: true, + lifecycle_summary: lifecyclePlanSummary(parsed), + }] : []); const existingNames = new Set(queries.map((q) => q.name)); incoming.forEach((q) => { let name = q.name; if (existingNames.has(name)) name = `${q.name} (${file.name})`; existingNames.add(name); - queries.push({ name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph }); + queries.push({ name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary }); }); } catch (err) { alert(`Failed to parse ${file.name}: ${err.message}`); @@ -116,6 +122,19 @@ function loadFiles(fileList) { fileInput.value = ''; } +function lifecyclePlanSummary(plan) { + return { + selected_raw_recompute: Boolean(plan.selected_raw_recompute), + summary_total_cost: plan.summary_total_cost ?? null, + raw_recompute_total_cost: plan.raw_recompute_total_cost ?? null, + horizon_seconds: plan.horizon_seconds ?? null, + evaluation_rate_per_second: plan.evaluation_rate_per_second ?? null, + update_rate_per_second: plan.update_rate_per_second ?? null, + expected_reads: plan.expected_reads ?? null, + deployment_count: Array.isArray(plan.deployments) ? plan.deployments.length : 0, + }; +} + function getParticipants() { return Array.from(participants) .filter((i) => i >= 0 && i < queries.length) @@ -388,6 +407,28 @@ function renderPrePostAsap() { return; } hideModeHint(); + if (selected.length === 1 && selected[0].lifecycle_plan) { + viewTitleEl.textContent = `Summary maintenance: ${selected[0].name}`; + const elements = laneElements( + 'summary-maintenance', + `${selected[0].name} · lifecycle plan`, + selected[0].post_graph, + selected[0], + 'post', + ); + buildCy(elements); + finalizeGraphInteractions(); + applyHighlighting(); + const initial = cy.nodes().filter((node) => !node.data('isLane') && node.data('root')).first(); + if (initial && initial.length) { + initial.select(); + showPrePostDetail(initial.data()); + } else { + clearDetail(); + } + fitAndSyncZoom(); + return; + } viewTitleEl.textContent = selected.length === 1 ? `Pre/Post-ASAP: ${selected[0].name}` : `Pre/Post-ASAP workload union: ${selected.length} queries`; @@ -527,6 +568,7 @@ function laneElements(laneId, laneLabel, graph, query, stage) { laneId, stage, queryName: query.name, + lifecycleSummary: query.lifecycle_summary, translations: translationsForNode(query, node, stage), }, }); @@ -652,6 +694,33 @@ function showPrePostDetail(data) { : ''; const decisions = data.translations || []; + const planSummary = data.lifecycleSummary; + let planSummaryHtml = ''; + if (planSummary) { + const value = (item) => item === null || item === undefined ? 'unknown' : String(item); + const selected = planSummary.selected_raw_recompute + ? 'Raw recomputation' + : 'Summary maintenance'; + planSummaryHtml = `

Lifecycle plan decision

+
Selected: ${escapeHtml(selected)}
+
summary cost: ${escapeHtml(value(planSummary.summary_total_cost))} · raw recompute cost: ${escapeHtml(value(planSummary.raw_recompute_total_cost))} · deployments: ${escapeHtml(value(planSummary.deployment_count))}
+
horizon: ${escapeHtml(value(planSummary.horizon_seconds))} s · expected reads: ${escapeHtml(value(planSummary.expected_reads))} · evaluation rate: ${escapeHtml(value(planSummary.evaluation_rate_per_second))}/s · update rate: ${escapeHtml(value(planSummary.update_rate_per_second))}/s
+
`; + } + const lifecycle = node.detail && node.detail.summary_maintenance; + let lifecycleHtml = ''; + if (lifecycle) { + const selected = lifecycle.selected; + const selectedText = selected + ? `${selected.lifecycle.kind} · ${selected.maintenance_mode} · ${selected.evaluation_schedule} · ${selected.output_representation}` + : 'No lifecycle selected'; + const alternatives = (lifecycle.alternatives || []).map((alternative) => { + const status = alternative.rejection ? `rejected: ${alternative.rejection}` : `cost: ${alternative.total_cost}`; + const assumptions = (alternative.assumptions || []).join('; ') || 'none'; + return `
${escapeHtml(alternative.lifecycle.kind)}
${escapeHtml(status)}
assumptions: ${escapeHtml(assumptions)}
`; + }).join(''); + lifecycleHtml = `

Summary maintenance lifecycle

Selected: ${escapeHtml(selectedText)}
${alternatives}
`; + } let translationHtml = ''; if (decisions.length > 0) { const cards = decisions.map((entry) => ` @@ -672,7 +741,9 @@ function showPrePostDetail(data) { ${escapeHtml(chipLabel)}
${escapeHtml(node.label)}
${rootHtml} + ${planSummaryHtml} ${translationHtml} + ${lifecycleHtml}

IR node content

${escapeHtml(JSON.stringify(node.detail, null, 2))}
`; @@ -796,7 +867,7 @@ document.getElementById('resetBtn').addEventListener('click', () => { zoom = 1; function loadWorkload(parsed) { const incoming = (parsed && parsed.queries) || []; - incoming.forEach((q) => queries.push({ name: q.name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph })); + incoming.forEach((q) => queries.push({ name: q.name, graph: q.graph, source: q.source, replacements: q.replacements || [], post_graph: q.post_graph, lifecycle_plan: q.lifecycle_plan, lifecycle_summary: q.lifecycle_summary })); if (activeIndex === -1 && queries.length > 0) activeIndex = 0; if (participants.size === 0 && activeIndex >= 0) participants.add(activeIndex); }