diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d6dda1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shared Rust/JS semantic trace must match generated LF-delimited output. +crates/twinkle-redux/tests/fixtures/shared_semantic_trace.txt text eol=lf diff --git a/Cargo.lock b/Cargo.lock index 0fdd1eb..84adc1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4067,6 +4067,7 @@ dependencies = [ "cosmic-text", "gilrs", "image", + "libc", "pollster", "proptest", "raw-window-handle", @@ -4081,6 +4082,8 @@ dependencies = [ "twinkle-redux", "twinkle-render-assets", "unicode-segmentation", + "wayland-client", + "wayland-protocols", "wgpu", "windows", "windows-core", diff --git a/crates/twinkle-charts/src/model.rs b/crates/twinkle-charts/src/model.rs index 670ac6f..5c06a5e 100644 --- a/crates/twinkle-charts/src/model.rs +++ b/crates/twinkle-charts/src/model.rs @@ -1150,17 +1150,11 @@ impl ChartModel { .unwrap_or(5) .clamp(2, self.limits.max_ticks_per_axis); let mut ticks = Size::default(); - for i in 0..count { - let start = mapper.map(domain[0]); - let end = mapper.map(domain[1]); - let value = - mapper.invert(start + (end - start) * i as f32 / (count - 1) as f32); - let size = measured_text( - &format_tick(value, axis.unit.as_deref()), - scale, - false, - None, - ); + let column = channel(&layer.encodings, axis_channel) + .and_then(|encoding| resolved.dataset.column(&encoding.field.column)) + .expect("resolved axis column"); + for (_, text) in axis_ticks(column, domain, &mapper, count, axis.unit.as_deref()) { + let size = measured_text(&text, scale, false, None); ticks.width = ticks.width.max(size.width); ticks.height = ticks.height.max(size.height); } @@ -1574,6 +1568,8 @@ impl ChartModel { y_scale, &x_mapper, &y_mapper, + x_column, + y_column, output, )?; let mut series_groups = series_groups(dataset, &layer.encodings, &self.state); @@ -1809,6 +1805,10 @@ impl ChartModel { let step = values.len().div_ceil(max_marks).max(1); let size_column = channel(&layer.encodings, Channel::Size) .and_then(|encoding| dataset.column(&encoding.field.column)); + let size_scale = channel(&layer.encodings, Channel::Size) + .and_then(|encoding| encoding.scale.as_ref()) + .and_then(|name| self.spec.scales.iter().find(|scale| &scale.name == name)); + let size_domain = size_scale.and_then(|scale| scale.domain); let size_max = size_column .into_iter() .flat_map(|column| column.values.iter()) @@ -1821,7 +1821,15 @@ impl ChartModel { .and_then(|column| column.values[*index].number()) .filter(|value| value.is_finite() && *value >= 0.0) .map_or(3.0, |value| { - if size_max == 0.0 { + if let Some(domain) = size_domain { + let mut normalized = (value - domain[0]) / (domain[1] - domain[0]); + if size_scale.is_some_and(|scale| scale.reversed) { + normalized = 1.0 - normalized; + } + // Explicit size domains preserve quantitative area across + // revisions and keep zero-size services inspectable. + (normalized.clamp(0.0, 1.0).sqrt() as f32 * 10.0).max(2.0) + } else if size_max == 0.0 { 0.0 } else { (value / size_max).sqrt() as f32 * 10.0 @@ -1865,8 +1873,12 @@ impl ChartModel { .filter_map(DataValue::number) .filter(|value| value.is_finite()) .collect(); - let fill_domain = - (!numeric_fill.is_empty()).then(|| extent(numeric_fill.iter().copied())); + let fill_scale = channel(&layer.encodings, Channel::Fill) + .and_then(|encoding| encoding.scale.as_ref()) + .and_then(|name| self.spec.scales.iter().find(|scale| &scale.name == name)); + let fill_domain = fill_scale.and_then(|scale| scale.domain).or_else(|| { + (!numeric_fill.is_empty()).then(|| extent(numeric_fill.iter().copied())) + }); for (index, x, y) in &values { let center = map(*x, *y); let left = (center.x - cell_width / 2.0).max(plot.origin.x); @@ -1876,9 +1888,13 @@ impl ChartModel { (center.y + cell_height / 2.0).min(plot.origin.y + plot.size.height); let value = fill_column.map(|column| &column.values[*index]); let cell_color = match (value, fill_domain) { - (Some(DataValue::Number(value)), Some(domain)) if value.is_finite() => self - .theme - .sequential((value - domain[0]) / (domain[1] - domain[0])), + (Some(DataValue::Number(value)), Some(domain)) if value.is_finite() => { + let mut normalized = (value - domain[0]) / (domain[1] - domain[0]); + if fill_scale.is_some_and(|scale| scale.reversed) { + normalized = 1.0 - normalized; + } + self.theme.sequential(normalized) + } (Some(DataValue::Category(_)), _) => { self.theme .series_cue( @@ -2284,6 +2300,8 @@ impl ChartModel { y_scale: Option<&str>, x_mapper: &AxisMapper, y_mapper: &AxisMapper, + x_column: &crate::Column, + y_column: &crate::Column, output: &mut RetainedSurfaceOutput, ) -> Result<(), ChartError> { for axis in &self.spec.axes { @@ -2310,19 +2328,22 @@ impl ChartModel { } else { continue; }; - for index in 0..count { - let t = index as f32 / (count - 1) as f32; + let ticks = axis_ticks( + if is_x { x_column } else { y_column }, + domain, + if is_x { x_mapper } else { y_mapper }, + count, + axis.unit.as_deref(), + ); + for (value, text) in ticks { if is_x { - let start = x_mapper.map(domain[0]); - let x = start + (x_mapper.map(domain[1]) - start) * t; - let value = x_mapper.invert(x); + let x = x_mapper.map(value); if axis.grid { output.paint.push(PaintCommand::Fill { rect: Rect::new(x, plot.origin.y, 1.0, plot.size.height), color: self.theme.grid, }); } - let text = format_tick(value, axis.unit.as_deref()); let size = measured_text(&text, text_scale, false, None); let width = plot.size.width.min(size.width); let origin = (x - width / 2.0).clamp( @@ -2349,16 +2370,13 @@ impl ChartModel { wrap: false, }); } else { - let start = y_mapper.map(domain[0]); - let y = start + (y_mapper.map(domain[1]) - start) * t; - let value = y_mapper.invert(y); + let y = y_mapper.map(value); if axis.grid { output.paint.push(PaintCommand::Fill { rect: Rect::new(plot.origin.x, y, plot.size.width, 1.0), color: self.theme.grid, }); } - let text = format_tick(value, axis.unit.as_deref()); let size = measured_text(&text, text_scale, false, None); let label_y = y - size.height * 0.5; let bounds = Rect::new( @@ -3740,6 +3758,55 @@ fn include_domain_value( } } +fn axis_ticks( + column: &crate::Column, + domain: [f64; 2], + mapper: &AxisMapper, + count: usize, + unit: Option<&str>, +) -> Vec<(f64, String)> { + if column + .values + .iter() + .all(|v| matches!(v, DataValue::Category(_) | DataValue::Null)) + && column + .values + .iter() + .any(|v| matches!(v, DataValue::Category(_))) + { + let mut seen = std::collections::BTreeSet::new(); + let labels = column + .values + .iter() + .filter_map(DataValue::category) + .filter(|label| seen.insert(*label)) + .enumerate() + .filter(|(index, _)| in_domain(*index as f64, domain)) + .map(|(index, label)| (index as f64, label.to_owned())) + .collect::>(); + if labels.len() <= count { + return labels; + } + return (0..count) + .map(|i| labels[i * (labels.len() - 1) / (count - 1)].clone()) + .collect(); + } + (0..count) + .map(|i| { + let fraction = i as f64 / (count - 1) as f64; + let value = if matches!(mapper, AxisMapper::Log(_)) { + mapper.invert( + mapper.map(domain[0]) + + (mapper.map(domain[1]) - mapper.map(domain[0])) * fraction as f32, + ) + } else { + domain[0] + (domain[1] - domain[0]) * fraction + }; + (value, format_tick(value, unit)) + }) + .collect() +} + fn format_tick(value: f64, unit: Option<&str>) -> String { let value = if value.abs() >= 1_000_000.0 || (value != 0.0 && value.abs() < 0.001) { format!("{value:.2e}") @@ -3946,13 +4013,6 @@ fn series_groups( let mut groups: Vec> = Vec::new(); for (index, value) in column.values.iter().enumerate() { let label = Arc::::from(format_data_value(value)); - if state - .visible_series - .as_ref() - .is_some_and(|visible| !visible.contains(&label)) - { - continue; - } let key = match value { DataValue::Null => "null".to_owned(), DataValue::Number(value) => format!("n:{:016x}", value.to_bits()), @@ -3964,7 +4024,15 @@ fn series_groups( groups.push(Vec::new()); groups.len() - 1 }); - groups[group].push(index); + // Reserve every admitted series' palette slot even when hidden, so a + // controlled legend toggle cannot recolor the remaining line/area data. + if state + .visible_series + .as_ref() + .is_none_or(|visible| visible.contains(&label)) + { + groups[group].push(index); + } } groups } @@ -5846,6 +5914,253 @@ mod tests { assert_eq!(radii, vec![5.0, 10.0]); } + #[test] + fn hiding_first_series_preserves_remaining_paint_and_legend_colors() { + let limits = ChartLimits::default(); + let data = Arc::new( + ChartData::new( + 1, + vec![ + Dataset::new( + "d", + vec![ + RowKey::from("a0"), + RowKey::from("b0"), + RowKey::from("a1"), + RowKey::from("b1"), + ], + vec![ + Column::new( + "x", + vec![ + DataValue::Number(0.0), + DataValue::Number(0.0), + DataValue::Number(1.0), + DataValue::Number(1.0), + ], + ), + Column::new( + "y", + vec![ + DataValue::Number(1.0), + DataValue::Number(2.0), + DataValue::Number(1.5), + DataValue::Number(2.5), + ], + ), + Column::new( + "series", + vec![ + DataValue::Category("A".into()), + DataValue::Category("B".into()), + DataValue::Category("A".into()), + DataValue::Category("B".into()), + ], + ), + ], + ) + .unwrap(), + ], + &limits, + ) + .unwrap(), + ); + let mut spec = + crate::Recipes::line("Series", FieldRef::new("d", "x"), FieldRef::new("d", "y")); + spec.layers[0].encodings.push(crate::Encoding { + channel: Channel::Series, + field: FieldRef::new("d", "series"), + scale: None, + }); + let mut view = + ChartView::new(ChartModel::new(Arc::new(spec), data, limits).unwrap()).unwrap(); + let input = |content| { + RetainedSurfaceInput::new( + Rect::new(0.0, 0.0, 600.0, 300.0), + 1.0, + twinkle_ui::retained_surface::RetainedSurfaceRevisions { + content, + ..Default::default() + }, + ) + }; + let colors = |paint: &[PaintCommand]| { + paint + .iter() + .filter_map(|p| match p { + PaintCommand::Path { color, .. } => Some(*color), + _ => None, + }) + .collect::>() + }; + let before = colors(&view.update(input(1)).unwrap().output.paint); + let after = colors( + &view + .update_state( + ChartState { + visible_series: Some([Arc::from("B")].into_iter().collect()), + ..Default::default() + }, + input(2), + ) + .unwrap() + .output + .paint, + ); + assert_eq!(before.len(), 2); + assert_eq!(after, vec![before[1]]); + } + + #[test] + fn categorical_guides_paint_source_labels_and_numeric_ticks_avoid_float_noise() { + let limits = ChartLimits::default(); + let dataset = Dataset::new( + "sources", + vec![RowKey::from("a"), RowKey::from("b")], + vec![ + Column::new( + "source", + vec![ + DataValue::Category("source-0042".into()), + DataValue::Category("source-0007".into()), + ], + ), + Column::new( + "value", + vec![DataValue::Number(1.0), DataValue::Number(2.0)], + ), + ], + ) + .unwrap(); + let data = Arc::new(ChartData::new(1, vec![dataset], &limits).unwrap()); + let mut spec = crate::Recipes::horizontal_bar( + "Sources", + FieldRef::new("sources", "source"), + FieldRef::new("sources", "value"), + ); + spec.axes.push(crate::Axis { + scale: "y".into(), + title: None, + unit: None, + tick_count: Some(5), + grid: false, + }); + let mut view = + ChartView::new(ChartModel::new(Arc::new(spec), data, limits).unwrap()).unwrap(); + let input = + RetainedSurfaceInput::new(Rect::new(0.0, 0.0, 600.0, 300.0), 1.0, Default::default()); + let paint = &view.update(input).unwrap().output.paint; + for label in ["source-0042", "source-0007"] { + assert!( + paint + .iter() + .any(|p| matches!(p,PaintCommand::Text{text,..} if text==label)) + ); + } + let column = Column::new("v", vec![DataValue::Number(445500.0)]); + let mapper = AxisMapper::new([0.0, 445500.0], [0.0, 100.0], None).unwrap(); + assert_eq!( + axis_ticks(&column, [0.0, 445500.0], &mapper, 5, None)[1].1, + "111375" + ); + } + + #[test] + fn explicit_color_and_size_domains_keep_values_stable_across_revisions() { + let limits = ChartLimits::default(); + let data = |revision, other| { + Arc::new( + ChartData::new( + revision, + vec![ + Dataset::new( + DatasetId::from("d"), + vec![ + RowKey::from("zero"), + RowKey::from("fixed"), + RowKey::from("changing"), + ], + vec![ + Column::new( + "x", + vec![ + DataValue::Number(0.0), + DataValue::Number(1.0), + DataValue::Number(2.0), + ], + ), + Column::new("y", vec![DataValue::Number(1.0); 3]), + Column::new( + "value", + vec![ + DataValue::Number(0.0), + DataValue::Number(25.0), + DataValue::Number(other), + ], + ), + ], + ) + .unwrap(), + ], + &limits, + ) + .unwrap(), + ) + }; + let field = |name| FieldRef::new("d", name); + for mut spec in [ + crate::Recipes::heatmap("Heat", field("x"), field("y"), field("value")), + crate::Recipes::bubble("Hosts", field("x"), field("y"), field("value")), + ] { + for scale in &mut spec.scales { + if matches!(scale.kind, crate::ScaleKind::Color | crate::ScaleKind::Size) { + scale.domain = Some([0.0, 100.0]); + } + } + let spec = Arc::new(spec); + let mut view = ChartView::new( + ChartModel::new(spec.clone(), data(1, 50.0), limits.clone()).unwrap(), + ) + .unwrap(); + let input = |revision| { + RetainedSurfaceInput::new( + Rect::new(0.0, 0.0, 600.0, 300.0), + 1.0, + twinkle_ui::retained_surface::RetainedSurfaceRevisions { + content: revision, + ..Default::default() + }, + ) + }; + let first = view.update(input(1)).unwrap().output.paint.clone(); + let second = view + .replace_and_update(spec, data(2, 100.0), input(2)) + .unwrap() + .output + .paint + .clone(); + // The zero and fixed marks must not change just because another + // datum's maximum changes. Guides are independent of size/fill. + let marks = |paint: &[PaintCommand]| { + paint + .iter() + .filter(|command| match command { + PaintCommand::Fill { rect, .. } => rect.size.width < 600.0, + PaintCommand::RoundedFill { radius, .. } => { + *radius == 2.0 || *radius == 5.0 + } + _ => false, + }) + .cloned() + .collect::>() + }; + let a = marks(&first); + let b = marks(&second); + assert!(a.len() >= 2, "must paint zero and fixed marks"); + assert_eq!(&a[..2], &b[..2]); + } + } + #[test] fn rejected_replacement_preserves_accepted_paint_hits_and_revision() { let model = model(); diff --git a/crates/twinkle-workbench/Cargo.toml b/crates/twinkle-workbench/Cargo.toml index eb3acda..464ca29 100644 --- a/crates/twinkle-workbench/Cargo.toml +++ b/crates/twinkle-workbench/Cargo.toml @@ -31,6 +31,11 @@ name = "twinkle-chart-admission" path = "src/bin/twinkle_chart_admission.rs" required-features = ["charts", "pipeline-allocator"] +[[bin]] +name = "twinkle-compound-admission" +path = "src/bin/twinkle_compound_admission.rs" +required-features = ["jsx"] + [features] pipeline-allocator = ["jsx"] pipeline-attribution = ["twinkle/pipeline-attribution", "twinkle-jsx-runtime?/pipeline-attribution"] diff --git a/crates/twinkle-workbench/src/bin/twinkle_compound_admission.rs b/crates/twinkle-workbench/src/bin/twinkle_compound_admission.rs new file mode 100644 index 0000000..a6d8e76 --- /dev/null +++ b/crates/twinkle-workbench/src/bin/twinkle_compound_admission.rs @@ -0,0 +1,303 @@ +//! Headless spec 0022 admission through the ordinary, cfg(test)-free UI library. +#[path = "pipeline_workloads/allocator.rs"] +mod allocator; + +use std::time::Instant; +use twinkle::backend::PaintCommand; +use twinkle::{ + Align, AnyView, Application, Column, ComponentBuilderExt, Container, Dropdown, + DropdownPartStyle, FrameRequest, HostBatch, HostEvent, InteractionPaint, Length, Point, Rect, + Slider, Text, UiEvent, UiFrame, UiHost, UiId, UiStateStore, View, ViewContext, +}; + +const BASE: u32 = 0xff223344; +const ACTIVE: u32 = 0xffcc1122; + +#[derive(Clone)] +struct Controls { + family: String, + scenario: String, + branches: usize, + options: usize, + revision: usize, +} + +impl Controls { + fn control(&self, id: String) -> AnyView<()> { + let paint = InteractionPaint { + background: Some(ACTIVE), + font_size: (self.scenario == "font").then_some(22.0), + ..Default::default() + }; + let part = DropdownPartStyle { + width: 120.0, + height: 24.0, + font_size: 14.0, + foreground: Some(0xffffffff), + background: Some(BASE), + interaction_paints: [paint; 3], + ..Default::default() + }; + let frame = DropdownPartStyle { + foreground: Some(0xffffffff), + ..Default::default() + }; + if self.family == "slider" { + AnyView::new( + Slider::new((), 0.5) + .parts( + part, + part, + DropdownPartStyle { + width: 16.0, + height: 16.0, + ..part + }, + ) + .css_frame(frame) + .id(id) + .width_length(Length::Auto) + .height_length(Length::Auto) + .shrink(0.0), + ) + } else { + let inline = self.scenario == "inline" || self.scenario == "many-options"; + AnyView::new( + Dropdown::new( + (), + "Selected label", + (0..self.options).map(|i| (format!("Option {i}"), ())), + ) + .overlay(!inline) + .open_generation(u64::from(inline)) + .parts( + DropdownPartStyle { + width: if self.scenario == "font" { 0.0 } else { 120.0 }, + ..part + }, + part, + DropdownPartStyle { + width: 16.0, + ..part + }, + ) + .css_frame(frame) + .id(id) + .width_length(Length::Auto) + .height_length(Length::Auto) + .shrink(0.0), + ) + } + } + + fn fixture(&self) -> AnyView<()> { + let mut children = vec![self.control("control".into())]; + if self.scenario == "dense" { + children.extend((0..63).map(|i| self.control(format!("dense-{i}")))); + } + children.extend((0..self.branches).map(|i| { + AnyView::new( + Container::new() + .id(if self.scenario == "structural" { + format!("branch-{i}-{}", self.revision) + } else { + format!("branch-{i}") + }) + .child( + Column::new() + .child( + Text::new(format!("Unrelated primary measurement text {i}")) + .wrap(true), + ) + .child(Text::new("Unrelated secondary measurement text").wrap(true)), + ), + ) + })); + AnyView::new(Column::new().align_items(Align::Start).children(children)) + } +} + +impl Application for Controls { + type Message = (); + fn update(&mut self, _: ()) {} + fn view(&self, _: ViewContext) -> impl View<()> { + self.fixture() + } +} + +fn distribution(mut samples: Vec) -> serde_json::Value { + let total: f64 = samples.iter().sum(); + samples.sort_by(f64::total_cmp); + let n = samples.len(); + serde_json::json!({"p50_us":samples[(n*50).div_ceil(100)-1], + "p95_us":samples[(n*95).div_ceil(100)-1],"total_us":total}) +} + +fn visible_active(commands: &[PaintCommand]) -> bool { + commands.iter().any(|command| { + matches!(command, + PaintCommand::Fill {color,..} | PaintCommand::RoundedFill {color,..} if *color == ACTIVE) + }) +} + +fn main() { + twinkle::install_pipeline_allocator_counter(allocator::counts); + let args = std::env::args().skip(1).collect::>(); + let arg = |key: &str, default: &str| { + args.iter() + .position(|a| a == key) + .and_then(|i| args.get(i + 1)) + .cloned() + .unwrap_or_else(|| default.into()) + }; + let family = arg("--family", "slider"); + let mode = arg("--mode", "retained"); + let scenario = arg("--scenario", "paint"); + assert!(["slider", "dropdown"].contains(&family.as_str())); + assert!(["cold", "retained"].contains(&mode.as_str())); + assert!( + [ + "paint", + "font", + "inline", + "many-options", + "dense", + "tiny", + "structural" + ] + .contains(&scenario.as_str()) + ); + let iterations: usize = arg("--iterations", "200").parse().unwrap(); + assert!(iterations >= 20); + let config = Controls { + family: family.clone(), + scenario: scenario.clone(), + branches: if scenario == "tiny" { 0 } else { 200 }, + options: if scenario == "many-options" { 1024 } else { 8 }, + revision: 0, + }; + let bounds = Rect::new(0.0, 0.0, 800.0, 720.0); + let mut state = UiStateStore::default(); + let mut host = (mode == "retained").then(|| UiHost::new(config.clone(), 800, 720)); + let mut complete = Vec::with_capacity(iterations); + let mut native = Vec::with_capacity(iterations); + let mut layout = Vec::with_capacity(iterations); + let mut allocation_delta = [0u64; 3]; + let mut active_frames = 0; + let mut node_count = 0; + let mut command_count = 0; + let mut active_control = None; + let mut inactive_control = None; + for sample in 0..iterations + 10 { + let active = scenario != "structural" && sample % 2 == 0; + let before = allocator::counts(); + let started = Instant::now(); + let (native_us, layout_us) = if mode == "cold" { + state.set_hovered(active.then(|| UiId::from("root/control"))); + let mut view = config.clone(); + view.revision = sample; + let declaration = view.fixture(); + let native_started = Instant::now(); + let frame = UiFrame::resolve(declaration, FrameRequest::new(bounds, &mut state)); + let elapsed = native_started.elapsed().as_secs_f64() * 1e6; + assert_eq!( + visible_active(frame.commands()), + active, + "paint transition must be visible" + ); + let control = &frame.resolved_layout().nodes()[1]; + let dimensions = [ + control.preferred.width, + control.preferred.height, + control.allocated.size.width, + control.allocated.size.height, + ]; + if active { + active_control = Some(dimensions); + } else { + inactive_control = Some(dimensions); + } + node_count = frame.resolved_layout().nodes().len(); + command_count = frame.commands().len(); + (elapsed, elapsed) + } else { + let host = host.as_mut().unwrap(); + // Apply a native-only interaction transaction first, matching the + // host's production invalidation/retained-paint path, then time a + // full application publication with that accepted transient state. + let control = host.resolved_layout().nodes()[1].allocated; + let point = Point { + x: control.origin.x + (control.size.width / 2.0).min(4.0), + y: control.origin.y + (control.size.height / 2.0).min(4.0), + }; + host.step(HostBatch { + events: vec![HostEvent::Ui(UiEvent::PointerMoved(if active { + point + } else { + Point { x: -1.0, y: -1.0 } + }))], + ..Default::default() + }); + host.application_mut().revision = sample; + let native_started = Instant::now(); + let outcome = host.step(HostBatch { + application_changed: true, + ..Default::default() + }); + let elapsed = native_started.elapsed().as_secs_f64() * 1e6; + assert!( + outcome.telemetry.rebuilt, + "full native publication must execute" + ); + assert_eq!( + visible_active(host.commands()), + active, + "paint transition must be visible" + ); + let control = &host.resolved_layout().nodes()[1]; + let dimensions = [ + control.preferred.width, + control.preferred.height, + control.allocated.size.width, + control.allocated.size.height, + ]; + if active { + active_control = Some(dimensions); + } else { + inactive_control = Some(dimensions); + } + node_count = host.resolved_layout().nodes().len(); + command_count = host.commands().len(); + (elapsed, outcome.telemetry.layout_us as f64) + }; + let complete_us = started.elapsed().as_secs_f64() * 1e6; + let after = allocator::counts(); + if sample >= 10 { + native.push(native_us); + layout.push(layout_us); + complete.push(complete_us); + active_frames += usize::from(active); + if let (Some(before), Some(after)) = (before, after) { + for i in 0..3 { + allocation_delta[i] += after[i] - before[i]; + } + } + } + } + let ownership = allocator::snapshot(); + let raw_samples = + serde_json::json!({"nativeFrame":native,"completeUpdate":complete,"layout":layout}); + println!( + "compound_admission={}", + serde_json::json!({ + "schema":1,"family":family,"mode":mode,"scenario":scenario, + "iterations":iterations,"warmups":10,"activeFrames":active_frames, + "viewport":[800,720],"nodeCount":node_count,"commandCount":command_count, + "activeControl":active_control,"inactiveControl":inactive_control, + "nativeFrame":distribution(native),"completeUpdate":distribution(complete),"layout":distribution(layout), + "allocatorEnabled":allocator::counts().is_some(),"allocationCalls":allocation_delta[0], + "deallocationCalls":allocation_delta[1],"requestedBytes":allocation_delta[2],"processOwnership":ownership,"rawSamplesUs":raw_samples, + "harnessCfgTest":cfg!(test),"scope":"Ordinary library, no cfg(test). Cold native/layout boundary is complete UiFrame resolution. Retained native is forced host publication, layout is host telemetry. Complete retained update additionally includes the preceding interaction transaction. Do not sum nested stages. Normal timing must disable attribution/counting allocator." + }) + ); +} diff --git a/crates/twinkle/Cargo.toml b/crates/twinkle/Cargo.toml index 1078d2d..74bdd5e 100644 --- a/crates/twinkle/Cargo.toml +++ b/crates/twinkle/Cargo.toml @@ -13,6 +13,7 @@ keywords = ["gui", "ui", "desktop", "declarative"] categories = ["gui"] [features] +wayland-presentation = ["dep:wayland-client", "dep:wayland-protocols", "dep:libc", "dep:raw-window-handle"] pipeline-attribution = ["dep:serde_json"] redux = ["dep:twinkle-redux"] default = ["native-controller", "gpu-presenter"] @@ -44,6 +45,12 @@ windows = { workspace = true, features = ["Win32_System_Com", "Win32_System_Ole" windows-core = "0.62.2" [dev-dependencies] +serde_json.workspace = true proptest.workspace = true sha2.workspace = true toml.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +wayland-client = { version = "0.31.15", features = ["system"], optional = true } +wayland-protocols = { version = "0.32.13", features = ["client"], optional = true } +libc = { version = "0.2", optional = true } diff --git a/crates/twinkle/src/lib.rs b/crates/twinkle/src/lib.rs index 7cedbd7..f16033e 100644 --- a/crates/twinkle/src/lib.rs +++ b/crates/twinkle/src/lib.rs @@ -48,6 +48,8 @@ pub mod text_editor; pub mod theme; #[allow(missing_docs)] mod ui; +#[cfg(all(target_os = "linux", feature = "wayland-presentation"))] +mod wayland_presentation; #[cfg(target_os = "linux")] pub use controller::NativeControllerDevice; @@ -94,11 +96,11 @@ pub use runtime::{ FileDragAction, FileDragEvent, FrameOverlay, GlobalAction, HostAdapter, HostBatch, HostChangeToken, HostEvent, HostEventOutcome, HostFailure, HostFailureStage, HostInputLease, HostInspection, HostServices, HostTelemetry, MessageEvidence, NativeFrameCorrelation, - NormalizedAdmissionBinding, NormalizedIngressAuthority, NormalizedInputEnvelope, - NormalizedRecipientBinding, NormalizedSourceBinding, OutboundFileDrag, - OverlayDeclarationFailure, Popover, ScrollAnchor, SemanticActionFailure, Shortcut, - ShortcutOutcome, Tooltip, TransientWindowSpawner, UiHost, UiHostViewport, ViewContext, run, - run_with_adapter, run_with_controller_source, + NativeFramePresentation, NativeFrameSubmission, NormalizedAdmissionBinding, + NormalizedIngressAuthority, NormalizedInputEnvelope, NormalizedRecipientBinding, + NormalizedSourceBinding, OutboundFileDrag, OverlayDeclarationFailure, Popover, ScrollAnchor, + SemanticActionFailure, Shortcut, ShortcutOutcome, Tooltip, TransientWindowSpawner, UiHost, + UiHostViewport, ViewContext, run, run_with_adapter, run_with_controller_source, }; #[cfg(target_os = "windows")] pub use runtime::{run_with_adapter_on_any_thread, run_with_controller_source_on_any_thread}; diff --git a/crates/twinkle/src/runtime.rs b/crates/twinkle/src/runtime.rs index 9484b19..ca9c66f 100644 --- a/crates/twinkle/src/runtime.rs +++ b/crates/twinkle/src/runtime.rs @@ -1,5 +1,8 @@ use crate::{ControllerSource, HostedControllerSource}; -#[cfg(feature = "gpu-presenter")] +#[cfg(any( + feature = "gpu-presenter", + all(target_os = "linux", feature = "wayland-presentation") +))] use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::{ any::Any, @@ -855,6 +858,19 @@ pub trait Application: Sized { None } + /// Observe a completed native presenter call for the captured frame identity. + /// This is CPU submission acknowledgement, never compositor presentation or + /// GPU completion. The callback does not trigger a declarative rebuild. + fn native_frame_submitted(&mut self, _submission: NativeFrameSubmission) {} + + /// Whether this standalone surface can deliver compositor presentation + /// timestamps. Presently optional Linux Wayland CLOCK_MONOTONIC feedback. + fn native_presentation_supported(&mut self, _supported: bool) {} + + /// Real compositor feedback for a captured frame, including discarded frames. + /// A missing timestamp is never proof of presentation. + fn native_frame_presented(&mut self, _presentation: NativeFramePresentation) {} + /// Drains the complete set of stable declared element IDs changed by the /// next non-structural view update. `None` requests conservative full-tree /// reconciliation. The host resolves IDs against the accepted frame and @@ -1837,8 +1853,11 @@ pub struct HostFailure { } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +/// Generations identifying the accepted frame and its accessibility projection. pub struct HostChangeToken { + /// Generation of the accepted native frame. pub frame_generation: u64, + /// Generation of the accepted accessibility projection. pub semantic_generation: u64, /// Incremental declaration that produced this frame, when one was /// admitted during the host step. Presenters carry this unchanged so @@ -1850,28 +1869,89 @@ pub struct HostChangeToken { /// its admitted declaration generation to native frame diagnostics. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub struct NativeFrameCorrelation { + /// Application mount identity captured when the declaration was admitted. pub mount: u64, + /// Declaration generation within that mount. pub generation: u64, } +/// Compositor presentation evidence. A discarded frame has no presentation +/// timestamp; an unavailable/invalid clock mapping also leaves it absent. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NativeFramePresentation { + /// Application correlation captured for the frame, if one was supplied. + pub correlation: Option, + /// Native frame generation associated with this feedback. + pub frame_generation: u64, + /// Mapped monotonic compositor timestamp, absent for discarded or unmappable feedback. + pub presented_at: Option, + /// Host monotonic time when compositor feedback was processed. + pub observed_at: Instant, + /// Whether the compositor discarded the frame without presenting it. + pub discarded: bool, + /// Reported display refresh interval, when available. + pub refresh_interval: Option, + /// Compositor presentation sequence, when available. + pub sequence: Option, +} + +/// CPU-side submission evidence delivered only after a successful native present +/// call. Correlation is captured with the submitted frame, not read from newer +/// application state when a software worker finishes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NativeFrameSubmission { + /// Application correlation captured with the submitted frame. + pub correlation: Option, + /// Native frame generation submitted to the presenter. + pub frame_generation: u64, + /// Host monotonic time immediately after the successful presenter call. + pub submitted_at: Instant, + /// CPU duration inside the native presenter call; not GPU execution time. + pub present_call_duration: Duration, + /// Presenter backend identifier, currently `gpu` or `software`. + pub backend: &'static str, + /// Logical-to-physical scale used for the submitted surface. + pub scale_factor: f32, + /// Submitted surface width and height in physical pixels. + pub physical_size: (u32, u32), +} + #[derive(Clone, Debug, PartialEq)] +/// Accepted host-step results, emitted work, failures and scheduling information. pub struct HostEventOutcome { + /// Whether the step changed accepted host or application state. pub changed: bool, + /// How the host handled the incoming event. pub disposition: crate::EventDisposition, + /// Combined invalidation requested by accepted work. pub invalidation: Invalidation, + /// Application messages delivered during this step. pub messages: Vec, + /// Effects produced while processing accepted messages. pub effects: Vec, + /// Host admission or execution failures reported during the step. pub failures: Vec, + /// Failures reported while completing deferred work. pub completion_failures: Vec, + /// Pointer icon requested by the accepted frame. pub pointer_icon: PointerIcon, + /// Whether the accepted frame requests native text input. pub text_input_active: bool, + /// Generation of the accepted accessibility projection. pub accessibility_generation: u64, + /// Accepted frame generations and application correlation. pub change_token: HostChangeToken, + /// Earliest scheduled host wake-up, when one is needed. pub next_deadline: Option, + /// Timing and work counters collected for this step. pub telemetry: HostTelemetry, + /// Text requested for transfer to the native clipboard. pub clipboard_text: Option, + /// Rejected accessibility actions and their failure evidence. pub semantic_failures: Vec, + /// Global actions emitted for the embedding application. pub global_actions: Vec, + /// Controller execution outcomes recorded during the step. pub controller_executions: Vec, } @@ -2258,7 +2338,7 @@ impl UiHost { /// Constructs a host against an explicit monotonic origin for deterministic /// scheduling tests and embedded adapters that already own the clock. - pub fn new_at(application: A, width: u32, height: u32, now: Instant) -> Self { + pub fn new_at(mut application: A, width: u32, height: u32, now: Instant) -> Self { let bounds = Rect::new(0.0, 0.0, width as f32, height as f32); let mut state = UiStateStore::default(); let context = ViewContext::from_host(bounds, &state, None::<&UiFrame>); @@ -2272,6 +2352,7 @@ impl UiHost { .poll_interval_controller(&crate::ControllerSnapshot::default()) .map(|interval| now + interval); let resources = tree.resource_diagnostics(); + let native_correlation = application.take_frame_correlation(); Self { tree_remote_access_protected: application.remote_access_protected(), application, @@ -2283,7 +2364,7 @@ impl UiHost { scale_factor: 1.0, input_dispatcher: FocusedInputDispatcher::default(), frame_generation: 1, - native_correlation: None, + native_correlation, pointer_icon: PointerIcon::Default, overlay_failures, next_application_deadline, @@ -4513,6 +4594,10 @@ struct ApplicationRuntime> { window: Option>, raster: AsyncRasterWorker, raster_revision: u64, + raster_correlation: Option, + raster_frame_generation: u64, + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + presentation_feedback: Option, input: twinkle_input::winit::Adapter, clipboard: Option, controller: Option, @@ -4639,6 +4724,10 @@ impl> ApplicationRuntime { window: None, raster, raster_revision: 0, + raster_correlation: None, + raster_frame_generation: 0, + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + presentation_feedback: None, input: twinkle_input::winit::Adapter::default(), clipboard: arboard::Clipboard::new().ok(), controller, @@ -5034,6 +5123,8 @@ impl> ApplicationRuntime { self.raster_revision = self.raster_revision.wrapping_add(1).max(1); let _snapshot_started = crate::pipeline_trace::clock(); let commands = host.commands().to_vec(); + self.raster_correlation = host.native_correlation; + self.raster_frame_generation = host.frame_generation; crate::pipeline_trace::event!({ "event": "software_submission", "surface": self.native_host_generation, "revision": self.raster_revision, "frame_generation": host.frame_generation, @@ -5075,6 +5166,8 @@ impl> ApplicationRuntime { return Ok(false); }; let size = window.surface_size(); + let frame_generation = host.frame_generation; + let correlation = host.native_correlation; let frame = host.render_frame(); let _presenter_started = crate::pipeline_trace::clock(); crate::pipeline_trace::event!({"event": "gpu_present_start", "frame_generation": host.frame_generation}); @@ -5084,6 +5177,14 @@ impl> ApplicationRuntime { } else { host.retained_paint_damage() }; + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + if let Some(feedback) = &mut self.presentation_feedback { + feedback.request(crate::wayland_presentation::Token { + correlation, + frame_generation, + })?; + } + let native_present_started = Instant::now(); let result = presenter.present( frame.logical_size.0, size.width, @@ -5093,9 +5194,23 @@ impl> ApplicationRuntime { ); self.authoritative_unchanged_present = false; result?; + let submitted_at = Instant::now(); + let present_call_duration = submitted_at.duration_since(native_present_started); crate::pipeline_trace::event!({"event": "gpu_present_end", "frame_generation": host.frame_generation, "presenter_call_us": crate::pipeline_trace::micros(_presenter_started), "boundary": "CPU presenter call including nested attribution writes; excludes compositor display"}); + if let Some(host) = self.host.as_mut() { + host.application + .native_frame_submitted(NativeFrameSubmission { + correlation, + frame_generation, + submitted_at, + present_call_duration, + backend: "gpu", + scale_factor: self.scale, + physical_size: (size.width, size.height), + }); + } Ok(true) } @@ -5132,7 +5247,19 @@ impl> ApplicationRuntime { } let _conversion_us = crate::pipeline_trace::micros(_transfer_started); let _present_started = crate::pipeline_trace::clock(); + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + if let Some(feedback) = &mut self.presentation_feedback { + feedback + .request(crate::wayland_presentation::Token { + correlation: self.raster_correlation, + frame_generation: self.raster_frame_generation, + }) + .map_err(std::io::Error::other)?; + } + let native_present_started = Instant::now(); buffer.present()?; + let submitted_at = Instant::now(); + let present_call_duration = submitted_at.duration_since(native_present_started); crate::pipeline_trace::event!({ "event": "software_present", "surface": self.native_host_generation, "revision": result.revision, "physical_width": result.width, "physical_height": result.height, @@ -5142,10 +5269,38 @@ impl> ApplicationRuntime { }); self.raster .acknowledge(self.native_host_generation, result.revision); + if let Some(host) = self.host.as_mut() { + host.application + .native_frame_submitted(NativeFrameSubmission { + correlation: self.raster_correlation, + frame_generation: self.raster_frame_generation, + submitted_at, + present_call_duration, + backend: "software", + scale_factor: self.scale, + physical_size: (size.width, size.height), + }); + } Ok(()) } fn tick(&mut self, event_loop: &dyn ActiveEventLoop) -> Option { + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + if let Some(feedback) = &mut self.presentation_feedback { + match feedback.dispatch() { + Ok(events) => { + if let Some(host) = &mut self.host { + for event in events { + host.application.native_frame_presented(event); + } + } + } + Err(error) => { + self.fail(event_loop, std::io::Error::other(error)); + return None; + } + } + } let now = Instant::now(); let Some(window) = self.window.clone() else { return None; @@ -5400,6 +5555,10 @@ impl> ApplicationRuntime { host.shutdown(); } self.surface = None; + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + { + self.presentation_feedback = None; + } self.window = None; self.raster.retire(self.native_host_generation); } @@ -5651,6 +5810,42 @@ impl> ApplicationHandler for ApplicationRuntim } self.host = Some(host); self.window = Some(window.clone()); + #[allow(unused_mut)] // Mutated only with optional Linux Wayland feedback. + let mut presentation_supported = false; + #[cfg(all(target_os = "linux", feature = "wayland-presentation"))] + if let (Ok(display), Ok(native_window)) = + (self.display.display_handle(), window.window_handle()) + { + if let ( + raw_window_handle::RawDisplayHandle::Wayland(display), + raw_window_handle::RawWindowHandle::Wayland(native_window), + ) = (display.as_raw(), native_window.as_raw()) + { + // SAFETY: this runtime owns both live winit handles and explicitly + // drops the guest feedback queue before its window/display owners. + match unsafe { + crate::wayland_presentation::Feedback::new( + display.display.as_ptr(), + native_window.surface.as_ptr(), + self.display.clone(), + window.clone(), + ) + } { + Ok(feedback) => { + presentation_supported = feedback.supported(); + self.presentation_feedback = Some(feedback); + } + Err(error) => { + tracing::warn!(%error, "Wayland presentation feedback unavailable") + } + } + } + } + if let Some(host) = &mut self.host { + host.application + .native_presentation_supported(presentation_supported); + } + #[cfg(feature = "gpu-presenter")] if std::env::var_os("TWINKLE_RENDERER").as_deref() != Some(std::ffi::OsStr::new("software")) { @@ -5994,6 +6189,36 @@ fn is_clipboard_paste(input: &twinkle_input::InputEvent) -> bool { #[cfg(test)] mod tests { + #[test] + fn initial_frame_captures_application_correlation_before_native_submission() { + struct Tagged(u64); + impl Application for Tagged { + type Message = u64; + fn update(&mut self, message: u64) { + self.0 = message; + } + fn view(&self, _: ViewContext) -> impl crate::View { + crate::Text::new(self.0.to_string()) + } + fn take_frame_correlation(&mut self) -> Option { + Some(crate::NativeFrameCorrelation { + mount: 42, + generation: self.0, + }) + } + } + let mut host = UiHost::new(Tagged(7), 200, 100); + let captured = host.native_correlation; + assert_eq!(captured.unwrap().generation, 7); + host.application_mut().update(8); + host.step(HostBatch { + application_changed: true, + ..Default::default() + }); + assert_eq!(host.native_correlation.unwrap().generation, 8); + assert_eq!(captured.unwrap().generation, 7); + } + use crate::ui::ComponentBuilderExt; static SYNTHETIC_INGRESS_ORDER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/twinkle/src/ui/retained.rs b/crates/twinkle/src/ui/retained.rs index 0af0b78..d98be83 100644 --- a/crates/twinkle/src/ui/retained.rs +++ b/crates/twinkle/src/ui/retained.rs @@ -702,10 +702,12 @@ fn kind_tag(kind: &Kind) -> KindTag { } fn node_independent_output_reuse_safe(element: &Element) -> bool { - !matches!( - element.kind, - Kind::VerticalScroll { .. } | Kind::Dropdown(_) - ) && !matches!(element.style.overflow_x, Overflow::Scroll | Overflow::Auto) + element.declared_layout_child().is_none() + && !matches!( + element.kind, + Kind::VerticalScroll { .. } | Kind::Dropdown(_) + ) + && !matches!(element.style.overflow_x, Overflow::Scroll | Overflow::Auto) && !matches!(element.style.overflow_y, Overflow::Scroll | Overflow::Auto) && element.style.proximity_magnification.is_none() } @@ -2338,11 +2340,11 @@ impl RetainedNodeArena { .iter() .map(|child| self.nodes[child].subtree_nodes) .sum::(); - let subtree_bulk_resolved_reuse_safe = - !matches!(kind, KindTag::Grid | KindTag::Scroll | KindTag::Dropdown) - && children - .iter() - .all(|child| self.nodes[child].subtree_bulk_resolved_reuse_safe); + let subtree_bulk_resolved_reuse_safe = element.declared_layout_child().is_none() + && !matches!(kind, KindTag::Grid | KindTag::Scroll | KindTag::Dropdown) + && children + .iter() + .all(|child| self.nodes[child].subtree_bulk_resolved_reuse_safe); let subtree_independent_output_reuse_safe = node_independent_output_reuse_safe(element) && children .iter() diff --git a/crates/twinkle/src/ui/tree.rs b/crates/twinkle/src/ui/tree.rs index 3e2e312..a95deef 100644 --- a/crates/twinkle/src/ui/tree.rs +++ b/crates/twinkle/src/ui/tree.rs @@ -319,7 +319,7 @@ pub struct InteractionState { /// Resolved common geometry and identity for one UI node. /// /// Optional navigation, scrolling, grid, interaction-index and semantic data -/// live in a lazily allocated [`ResolvedNodeDetails`] sidecar. Direct field +/// live in a lazily allocated sidecar. Direct field /// access remains available through deref; exhaustive struct literals are an /// intentional 0.2 representation boundary. pub struct ResolvedNode { @@ -2463,6 +2463,7 @@ impl UiFrame { .has_value_overrides() .then_some(&transient_overlay); let mut measurement = MeasurementSession::default(); + measurement.prepare_boundaries(&root, &root_id, &transient_overlay, &tree.retained_nodes); layout_element( &root, &root_id, @@ -8645,9 +8646,27 @@ impl Hasher for MeasurementNodeHasher { type MeasurementNodeIndex = HashMap>; +#[cfg(test)] +thread_local! { + // 0 optimized, 1 textual identities, 2 whole-overlay measurement oracle. + static MEASUREMENT_ORACLE_MODE: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +struct MeasurementBoundaryIdentity { + parent: UiId, + index: usize, + id: UiId, + #[cfg(any(test, feature = "pipeline-attribution"))] + requests: std::cell::Cell, +} + struct MeasurementSession { #[cfg(test)] enabled: bool, + #[cfg(any(test, feature = "pipeline-attribution"))] + boundary_counts: std::cell::Cell<[u64; 6]>, + boundary_ids: + HashMap>, node_index: MeasurementNodeIndex, nodes: Vec, entries: usize, @@ -8671,11 +8690,152 @@ struct MeasurementSession { purpose_requests: [u64; 4], } +impl MeasurementSession { + // Only call immediately after reconciling this immutable declaration. The map + // never survives this layout session; auxiliary/generated declarations fall + // back to textual resolution. No retained slot or borrowed address escapes. + fn prepare_boundaries( + &mut self, + element: &Element, + id: &UiId, + overlay: &TransientStateJournal, + retained: &super::retained::RetainedNodeArena, + ) { + #[cfg(test)] + if MEASUREMENT_ORACLE_MODE.get() == 1 { + return; + } + let started = crate::pipeline_trace::clock(); + #[cfg(feature = "pipeline-attribution")] + let allocation_before = crate::pipeline_trace::allocator_counts(); + self.boundary_ids.clear(); + if !overlay.measurement_affects(id) { + self.boundary_ids.shrink_to_fit(); + } + self.seed_boundaries(element, id, overlay, retained); + #[cfg(feature = "pipeline-attribution")] + let allocation_delta = allocation_before + .zip(crate::pipeline_trace::allocator_counts()) + .map(|(before, after)| { + [ + after[0] - before[0], + after[1] - before[1], + after[2] - before[2], + ] + }); + crate::pipeline_trace::event!({ + "event": "measurement_boundary_preparation", + "entries": self.boundary_ids.len(), + "capacity": self.boundary_ids.capacity(), + "capacityInlineBytes": self.boundary_ids.capacity() * std::mem::size_of::<(usize, MeasurementBoundaryIdentity)>(), + "estimatedTableBytes": self.boundary_table_bytes(), + "allocationDeltaCallsFreesRequestedBytes": allocation_delta, + "serviceUs": crate::pipeline_trace::micros(started), + "maxEntries": 16_384, + "rootMembershipProbes": 1, + "ownership": "Session owns two Arc UiId handles per boundary, sharing existing identity payloads. Table estimate includes spare buckets and control bytes for this compiler's standard HashMap; allocator bookkeeping/rounding is additional. Attribution/test entries include an extra eight-byte request counter; normal entries do not. All capacity is released at session end." + }); + let _ = started; + } + + #[cfg(any(test, feature = "pipeline-attribution"))] + fn boundary_table_bytes(&self) -> usize { + if self.boundary_ids.capacity() == 0 { + return 0; + } + let buckets = (self.boundary_ids.capacity() + 1).next_power_of_two(); + buckets * (std::mem::size_of::<(usize, MeasurementBoundaryIdentity)>() + 1) + 16 + } + + fn seed_boundaries( + &mut self, + element: &Element, + id: &UiId, + overlay: &TransientStateJournal, + retained: &super::retained::RetainedNodeArena, + ) { + const MAX_BOUNDARIES: usize = 16_384; + #[cfg(any(test, feature = "pipeline-attribution"))] + { + let mut counts = self.boundary_counts.get(); + counts[3] += 1; + self.boundary_counts.set(counts); + } + if !overlay.measurement_affects(id) { + return; + } + for (index, child) in element.children.iter().enumerate() { + if self.boundary_ids.len() >= MAX_BOUNDARIES { + return; + } + #[cfg(any(test, feature = "pipeline-attribution"))] + { + let mut counts = self.boundary_counts.get(); + counts[4] += 1; + self.boundary_counts.set(counts); + } + let Some(child_id) = retained.child_ui_id(id, index) else { + continue; + }; + // Verify textual parent/position semantics without constructing or + // fingerprinting a fresh path. This also rejects ambiguous duplicate + // parent lookups whose retained children belong to another declaration. + if !layout::child_identity_matches(id, child, index, &child_id) { + continue; + } + self.boundary_ids.insert( + child as *const Element as usize, + MeasurementBoundaryIdentity { + parent: id.clone(), + index, + id: child_id.clone(), + #[cfg(any(test, feature = "pipeline-attribution"))] + requests: std::cell::Cell::new(0), + }, + ); + self.seed_boundaries(child, &child_id, overlay, retained); + } + } + + fn boundary_id(&self, child: &Element, parent: &UiId, index: usize) -> UiId { + if let Some(identity) = self + .boundary_ids + .get(&(child as *const Element as usize)) + && identity.parent == *parent + && identity.index == index + { + #[cfg(any(test, feature = "pipeline-attribution"))] + { + let mut counts = self.boundary_counts.get(); + counts[0] += 1; + counts[5] += u64::from(identity.requests.get() != 0); + identity.requests.set(identity.requests.get() + 1); + self.boundary_counts.set(counts); + } + return identity.id.clone(); + } + let id = layout::resolved_child_id(parent, child, index); + #[cfg(any(test, feature = "pipeline-attribution"))] + { + let mut counts = self.boundary_counts.get(); + counts[1] += 1; + counts[2] += id.as_str().len() as u64; + self.boundary_counts.set(counts); + } + crate::pipeline_trace::count_work!(9, 1); + crate::pipeline_trace::count_work!(10, id.as_str().len() as u64); + id + } +} + impl Default for MeasurementSession { fn default() -> Self { Self { #[cfg(test)] enabled: true, + #[cfg(any(test, feature = "pipeline-attribution"))] + boundary_counts: std::cell::Cell::new([0; 6]), + boundary_ids: HashMap::default(), node_index: MeasurementNodeIndex::default(), nodes: Vec::new(), entries: 0, @@ -8736,6 +8896,13 @@ impl Drop for MeasurementSession { fn drop(&mut self) { crate::pipeline_trace::event!({ "event": "measurement_session", + "boundaryReused": self.boundary_counts.get()[0], + "boundaryTextualFallbacks": self.boundary_counts.get()[1], + "boundaryTextualBytes": self.boundary_counts.get()[2], + "boundaryEntries": self.boundary_ids.len(), + "boundaryPreparationSeedMembershipProbes": self.boundary_counts.get()[3], + "boundaryPreparationArenaRequests": self.boundary_counts.get()[4], + "boundaryRepeatedReuses": self.boundary_counts.get()[5], "requests": self.requests, "evaluations": self.evaluations, "hits": self.hits, @@ -8765,9 +8932,7 @@ fn measure_child_effective( ) -> Size { match parent { Some((parent_id, overlay)) => { - let child_id = layout::resolved_child_id(parent_id, child, index); - crate::pipeline_trace::count_work!(9, 1); - crate::pipeline_trace::count_work!(10, child_id.as_str().len() as u64); + let child_id = session.boundary_id(child, parent_id, index); let child_context = overlay .measurement_affects(&child_id) .then_some((&child_id, overlay)); @@ -8781,13 +8946,12 @@ fn effective_child_style<'a, Message>( child: &'a Element, parent: Option<(&UiId, &'a TransientStateJournal)>, index: usize, + session: &MeasurementSession, ) -> EffectiveStyle<'a> { crate::pipeline_trace::count_work!(11, 1); match parent { Some((parent_id, overlay)) => { - let child_id = layout::resolved_child_id(parent_id, child, index); - crate::pipeline_trace::count_work!(9, 1); - crate::pipeline_trace::count_work!(10, child_id.as_str().len() as u64); + let child_id = session.boundary_id(child, parent_id, index); if overlay.measurement_affects(&child_id) { crate::pipeline_trace::count_work!(8, 1); overlay.style(&child_id, child) @@ -9118,7 +9282,7 @@ fn evaluate_element_effective( MeasurementPurpose::Intrinsic, session, ); - let position = effective_child_style(child, context, index) + let position = effective_child_style(child, context, index, session) .absolute_position .unwrap_or_default(); Size::new(position.x + size.width, position.y + size.height) @@ -9158,7 +9322,7 @@ fn evaluate_element_effective( .enumerate() .zip(&sizes) .map(|((index, child), measured)| { - let child_style = effective_child_style(child, context, index); + let child_style = effective_child_style(child, context, index, session); let preferred = child_style.basis.resolve( child_max.width, child_style.width.resolve(child_max.width, measured.width), @@ -9669,3 +9833,7 @@ fn contains(rect: Rect, point: Point) -> bool { #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "tree/measurement_admission.rs"] +mod measurement_admission; diff --git a/crates/twinkle/src/ui/tree/layout.rs b/crates/twinkle/src/ui/tree/layout.rs index 965fe86..7c2c151 100644 --- a/crates/twinkle/src/ui/tree/layout.rs +++ b/crates/twinkle/src/ui/tree/layout.rs @@ -432,7 +432,7 @@ pub(super) fn layout_element( let content = rect.inset(style.padding); for (index, child) in element.children.iter().enumerate() { let child_context = measurement_context; - let position = effective_child_style(child, child_context, index) + let position = effective_child_style(child, child_context, index, measurement) .absolute_position .unwrap_or_default(); let size = measure_child_effective( @@ -510,10 +510,12 @@ pub(super) fn layout_element( .enumerate() .map(|(index, child)| match axis { Axis::Horizontal => { - effective_child_style(child, measurement_context, index).min_width + effective_child_style(child, measurement_context, index, measurement) + .min_width } Axis::Vertical => { - effective_child_style(child, measurement_context, index).min_height + effective_child_style(child, measurement_context, index, measurement) + .min_height } }) .sum::() @@ -925,9 +927,10 @@ pub(super) fn layout_element( let row_y = content.origin.y - offset_y + row_heights[..row].iter().sum::() + style.gap * row as f32; - let alignment = effective_child_style(child, measurement_context, index) - .align_self - .unwrap_or(style.align_items); + let alignment = + effective_child_style(child, measurement_context, index, measurement) + .align_self + .unwrap_or(style.align_items); let item_height = measured[index].height.min(row_heights[row]); let row_baseline = if alignment == Align::Baseline { measured[row * column_count..measured.len().min((row + 1) * column_count)] @@ -1002,6 +1005,26 @@ pub(super) fn resolved_child_id( ) } +pub(super) fn child_identity_matches( + parent: &UiId, + child: &Element, + index: usize, + candidate: &UiId, +) -> bool { + candidate + .as_str() + .strip_prefix(parent.as_str()) + .and_then(|tail| tail.strip_prefix('/')) + .is_some_and(|suffix| match child.id.as_ref() { + Some(key) => suffix == key.as_str(), + None => suffix.strip_prefix('#').is_some_and(|digits| { + (digits == "0" || !digits.starts_with('0')) + && digits.bytes().all(|digit| digit.is_ascii_digit()) + && digits.parse::() == Ok(index) + }), + }) +} + fn retained_child_id( tree: &UiFrame, parent: &UiId, @@ -1021,6 +1044,7 @@ fn retained_child_id_from( ) -> UiId { retained .and_then(|retained| retained.child_ui_id(parent, index)) + .filter(|candidate| child_identity_matches(parent, child, index, candidate)) .unwrap_or_else(|| resolved_child_id(parent, child, index)) } @@ -1514,7 +1538,7 @@ impl TransientStateJournal { pub(super) fn measurement_affects(&self, id: &UiId) -> bool { #[cfg(test)] - if self.force_measurement_overlay { + if self.force_measurement_overlay || MEASUREMENT_ORACLE_MODE.get() == 2 { return self.has_value_overrides(); } self.measurement_affected.contains(id) @@ -3180,7 +3204,7 @@ fn flex_bounds( .enumerate() .zip(&measured) .map(|((index, child), measured)| { - let style = effective_child_style(child, context, index); + let style = effective_child_style(child, context, index, measurement); let (intrinsic, min, max) = match axis { Axis::Horizontal => (measured.width, style.min_width, style.max_width), Axis::Vertical => (measured.height, style.min_height, style.max_height), @@ -3249,7 +3273,7 @@ fn flex_bounds( .enumerate() .zip(&resolved_measured) .filter(|((index, child), _)| { - effective_child_style(child, context, *index) + effective_child_style(child, context, *index, measurement) .align_self .unwrap_or(align_items) == Align::Baseline @@ -3265,7 +3289,7 @@ fn flex_bounds( .zip(&resolved_measured) .enumerate() { - let style = effective_child_style(child, context, index); + let style = effective_child_style(child, context, index, measurement); let main_offset = leading + index as f32 * extra_gap; let alignment = style.align_self.unwrap_or(align_items); match axis { @@ -3396,6 +3420,164 @@ mod transient_journal_tests { .into_element() } + #[test] + fn measurement_boundary_session_reuses_only_current_parent_positions() { + let root = dependency_fixture(); + let root_id = UiId::from("root"); + let mut arena = super::super::super::retained::RetainedNodeArena::default(); + arena.reconcile_against(&root, None); + let mut journal = TransientStateJournal::default(); + let mut session = MeasurementSession::default(); + session.prepare_boundaries(&root, &root_id, &journal, &arena); + assert_eq!(session.boundary_ids.capacity(), 0); + journal.style_patches.insert( + UiId::from("root/left/target"), + TransientStylePatch { + width: Some(Length::Px(40.0)), + ..Default::default() + }, + ); + journal.finalize(&root, &root_id, Some(&arena)); + session.prepare_boundaries(&root, &root_id, &journal, &arena); + assert_eq!(session.boundary_ids.len(), 3); + let mut other_mount = super::super::super::retained::RetainedNodeArena::default(); + other_mount.reconcile_against(&root, None); + let mut other_session = MeasurementSession::default(); + other_session.prepare_boundaries(&root, &root_id, &journal, &other_mount); + let first = session.boundary_id(&root.children[0], &root_id, 0); + let second = other_session.boundary_id(&root.children[0], &root_id, 0); + assert_eq!(first, second); + assert_ne!(first.mounted_token(), second.mounted_token()); + for _ in 0..10 { + assert_eq!( + session.boundary_id(&root.children[0], &root_id, 0), + UiId::from("root/left") + ); + } + // An address hit with a foreign parent must resolve through fallback. + assert_eq!( + session.boundary_id(&root.children[0], &UiId::from("other"), 0), + UiId::from("other/left") + ); + let constraints = Constraints::loose(Size::new(300.0, 200.0)); + let optimized = measure_element_effective( + &root, + Some((&root_id, &journal)), + constraints, + MeasurementPurpose::Intrinsic, + &mut session, + ); + let textual = measure_element_effective( + &root, + Some((&root_id, &journal)), + constraints, + MeasurementPurpose::Intrinsic, + &mut MeasurementSession::default(), + ); + assert_eq!(optimized, textual); + assert!(session.boundary_counts.get()[0] >= 10); + assert_eq!(session.boundary_counts.get()[1], 1); + + let changed = Row::<()>::new() + .child(Text::new("new").id("new")) + .into_element(); + session.prepare_boundaries(&changed, &root_id, &journal, &arena); + assert!(session.boundary_ids.is_empty()); + assert_eq!( + session.boundary_id(&changed.children[0], &root_id, 0), + UiId::from("root/new") + ); + } + + #[test] + fn measurement_boundary_session_budget_and_empty_contraction() { + let root = Row::<()>::new() + .children((0..20_000).map(|_| Text::new("x"))) + .into_element(); + let root_id = UiId::from("root"); + let mut arena = super::super::super::retained::RetainedNodeArena::default(); + arena.reconcile_against(&root, None); + let mut journal = TransientStateJournal::default(); + journal.style_patches.insert( + root_id.clone(), + TransientStylePatch { + width: Some(Length::Px(40.0)), + ..Default::default() + }, + ); + journal.style_patches.insert( + UiId::from("root/#19999"), + TransientStylePatch { + width: Some(Length::Px(80.0)), + ..Default::default() + }, + ); + journal.finalize(&root, &root_id, Some(&arena)); + let mut session = MeasurementSession::default(); + session.prepare_boundaries(&root, &root_id, &journal, &arena); + assert_eq!(session.boundary_ids.len(), 16_384); + assert!(session.boundary_ids.capacity() <= 28_672); + assert!(session.boundary_table_bytes() <= 1_343_504); + // A growing hash table may temporarily own the old and new buckets. + // Each is bounded by the admitted final capacity; payloads remain shared. + assert!(session.boundary_table_bytes() * 2 <= 2_687_008); + assert_eq!( + session.boundary_id(&root.children[19_999], &root_id, 19_999), + UiId::from("root/#19999") + ); + let fallback_size = measure_child_effective( + &root.children[19_999], + Some((&root_id, &journal)), + 19_999, + Constraints::loose(Size::new(200.0, 200.0)), + MeasurementPurpose::Intrinsic, + &mut session, + ); + assert_eq!( + fallback_size.width, 80.0, + "budget fallback must preserve a live metric patch" + ); + session.prepare_boundaries(&root, &root_id, &TransientStateJournal::default(), &arena); + assert_eq!(session.boundary_table_bytes(), 0); + drop(session); + let mut empty = MeasurementSession::default(); + empty.prepare_boundaries(&root, &root_id, &TransientStateJournal::default(), &arena); + assert_eq!(empty.boundary_ids.capacity(), 0); + } + + #[test] + fn measurement_boundary_broad_deep_scaling_is_bounded() { + std::thread::Builder::new().stack_size(64 * 1024 * 1024).spawn(|| { + for nodes in [64, 256, 1_024, 4_096] { + for deep in [false, true] { + let root: Element<()> = if deep { + let mut root = Container::new().into_element(); + for _ in 1..nodes { root = Container::new().child(root).into_element(); } + root + } else { + Row::new().children((1..nodes).map(|_| Container::new())).into_element() + }; + let root_id = UiId::from("root"); + let target = if deep { UiId::from(format!("root{}", "/#0".repeat(nodes - 1))) } else { root_id.clone() }; + let mut arena = super::super::super::retained::RetainedNodeArena::default(); + arena.reconcile_against(&root, None); + let mut journal = TransientStateJournal::default(); + journal.style_patches.insert(target, TransientStylePatch { width: Some(Length::Px(40.0)), ..Default::default() }); + journal.finalize(&root, &root_id, Some(&arena)); + let mut session = MeasurementSession::default(); + session.prepare_boundaries(&root, &root_id, &journal, &arena); + assert_eq!(session.boundary_ids.len(), nodes - 1); + assert!(session.boundary_ids.capacity() <= nodes * 2); + for identity in session.boundary_ids.values() { + assert!(identity.id.as_str().starts_with(identity.parent.as_str())); + assert!(identity.index < nodes); + } + eprintln!("measurement_boundary_scaling nodes={nodes} deep={deep} entries={} capacity={} inline_bytes={}", session.boundary_ids.len(), session.boundary_ids.capacity(), session.boundary_ids.capacity() * std::mem::size_of::<(usize, MeasurementBoundaryIdentity)>()); + } + } + }).unwrap().join().unwrap(); + } + #[test] fn measurement_dependency_summary_closes_over_ancestors_not_siblings() { let root = dependency_fixture(); diff --git a/crates/twinkle/src/ui/tree/measurement_admission.rs b/crates/twinkle/src/ui/tree/measurement_admission.rs new file mode 100644 index 0000000..54546de --- /dev/null +++ b/crates/twinkle/src/ui/tree/measurement_admission.rs @@ -0,0 +1,147 @@ +//! Spec 0021's frame, guard and attribution fixtures. Timing and attribution builds are separate. +use super::*; +use std::time::Instant; +#[path = "measurement_allocator.rs"] +mod allocator; + +fn fixture(branches: usize, dense: bool, revision: usize, structural: bool) -> Element<()> { + let mut root = Row::new() + .gap(2.0) + .children((0..branches).map(|index| { + let branch = Container::new().id(if structural { + format!("branch-{index}-{revision}") + } else { + format!("branch-{index}") + }); + let paints = [ + InteractionPaint::default(), + InteractionPaint { + width: Some(Length::Px(96.0)), + height: Some(Length::Px(48.0)), + ..Default::default() + }, + InteractionPaint::default(), + ]; + let branch = if dense { + branch.part_interaction_paints(paints) + } else if index == 0 { + branch.message(()).interaction_paints(paints) + } else { + branch + }; + let branch = branch + .child( + Column::new() + .child( + Text::new(format!("branch {index} primary measurement text")) + .wrap(true), + ) + .child(Text::new("secondary measurement text").wrap(true)), + ) + .into_element(); + branch + })) + .into_element(); + if dense { + root = root.message(()); + } + root +} + +#[test] +#[ignore = "serialized spec 0021 release admission and separate attribution fixture"] +fn measurement_boundary_native_frame_release_admission() { + crate::install_pipeline_allocator_counter(allocator::counts); + let case = std::env::var("TWINKLE_MEASUREMENT_CASE").unwrap_or_else(|_| "sparse-cold".into()); + assert!( + [ + "sparse-cold", + "sparse-retained", + "tiny", + "dense", + "structural" + ] + .contains(&case.as_str()) + ); + let branches = if case == "tiny" { 1 } else { 200 }; + let dense = case == "dense"; + let structural = case == "structural"; + let retained = case != "sparse-cold" && !structural; + let iterations = 200; + let bounds = Rect::new(0.0, 0.0, 800.0, 720.0); + let mut state = UiStateStore::default(); + if !structural { + state.set_pressed(Some(UiId::from(if dense { + "root" + } else { + "root/branch-0" + }))); + } + let mut previous = UiFrame::resolve( + fixture(branches, dense, 0, structural), + FrameRequest::new(bounds, &mut state), + ); + for revision in 1..=10 { + let declaration = fixture(branches, dense, revision, structural); + previous = if retained { + UiFrame::resolve_against_reusing( + declaration, + FrameRequest::new(bounds, &mut state), + &mut previous, + ) + } else { + UiFrame::resolve(declaration, FrameRequest::new(bounds, &mut state)) + }; + } + let mut native = Vec::with_capacity(iterations); + let mut complete = Vec::with_capacity(iterations); + let mut allocations = [0u64; 3]; + for iteration in 0..iterations { + let before = allocator::counts(); + let started = Instant::now(); + let declaration = fixture(branches, dense, iteration + 11, structural); + let native_started = Instant::now(); + let frame = if retained { + UiFrame::resolve_against_reusing( + declaration, + FrameRequest::new(bounds, &mut state), + &mut previous, + ) + } else { + UiFrame::resolve(declaration, FrameRequest::new(bounds, &mut state)) + }; + native.push(native_started.elapsed().as_secs_f64() * 1_000_000.0); + complete.push(started.elapsed().as_secs_f64() * 1_000_000.0); + if let (Some(before), Some(after)) = (before, allocator::counts()) { + for index in 0..3 { + allocations[index] += after[index] - before[index]; + } + } + std::hint::black_box(frame.resolved_layout().nodes().len()); + previous = frame; + } + let ownership = allocator::snapshot(); + let native_total: f64 = native.iter().sum(); + let complete_total: f64 = complete.iter().sum(); + native.sort_by(f64::total_cmp); + complete.sort_by(f64::total_cmp); + let nodes = previous.resolved_layout().nodes().len(); + assert_eq!(nodes, branches * 4 + 1); + let cold = UiFrame::resolve( + fixture(branches, dense, iterations + 10, structural), + FrameRequest::new(bounds, &mut state.clone()), + ); + assert_eq!(previous.commands(), cold.commands()); + assert_eq!(previous.resolved_layout(), cold.resolved_layout()); + eprintln!( + "measurement_boundary_admission={}", + serde_json::json!({ + "case": case, "nodes": nodes, "warmups": 10, "iterations": iterations, + "nativeFrame": {"p50_us":native[iterations/2], "p95_us":native[iterations*95/100-1], "total_us":native_total}, + "completeUpdate": {"p50_us":complete[iterations/2], "p95_us":complete[iterations*95/100-1], "total_us":complete_total}, + "allocationCalls": allocations[0], "deallocationCalls": allocations[1], "requestedBytes": allocations[2], + "allocatorEnabled": allocator::counts().is_some(), "processOwnership": ownership, + "scope": "Complete update includes declaration construction. Native frame excludes it. Neither includes a JSX producer. Allocation deltas cover measured calls; process peak includes initialization/warmups and global font caches. Allocator bookkeeping/fragmentation and foreign allocations are excluded." + }) + ); +} diff --git a/crates/twinkle/src/ui/tree/measurement_allocator.rs b/crates/twinkle/src/ui/tree/measurement_allocator.rs new file mode 100644 index 0000000..97e2426 --- /dev/null +++ b/crates/twinkle/src/ui/tree/measurement_allocator.rs @@ -0,0 +1,82 @@ +//! Process-wide Rust allocation requests only; foreign C++/driver allocations excluded. +#[cfg(feature = "pipeline-attribution")] +mod enabled { + use std::{ + alloc::{GlobalAlloc, Layout, System}, + sync::atomic::{AtomicUsize, Ordering}, + }; + struct Counting; + static LIVE: AtomicUsize = AtomicUsize::new(0); + static PEAK: AtomicUsize = AtomicUsize::new(0); + static CALLS: AtomicUsize = AtomicUsize::new(0); + static FREES: AtomicUsize = AtomicUsize::new(0); + static REQUESTED: AtomicUsize = AtomicUsize::new(0); + fn add(size: usize) { + let live = LIVE.fetch_add(size, Ordering::Relaxed) + size; + PEAK.fetch_max(live, Ordering::Relaxed); + CALLS.fetch_add(1, Ordering::Relaxed); + REQUESTED.fetch_add(size, Ordering::Relaxed); + } + unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc(l) }; + if !p.is_null() { + add(l.size()); + } + p + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc_zeroed(l) }; + if !p.is_null() { + add(l.size()); + } + p + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIVE.fetch_sub(l.size(), Ordering::Relaxed); + FREES.fetch_add(1, Ordering::Relaxed); + unsafe { System.dealloc(p, l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, size: usize) -> *mut u8 { + let n = unsafe { System.realloc(p, l, size) }; + if !n.is_null() { + if size >= l.size() { + let live = LIVE.fetch_add(size - l.size(), Ordering::Relaxed) + size - l.size(); + PEAK.fetch_max(live, Ordering::Relaxed); + } else { + LIVE.fetch_sub(l.size() - size, Ordering::Relaxed); + } + CALLS.fetch_add(1, Ordering::Relaxed); + REQUESTED.fetch_add(size, Ordering::Relaxed); + } + n + } + } + #[global_allocator] + static ALLOCATOR: Counting = Counting; + pub fn counts() -> [u64; 3] { + use Ordering::Relaxed; + [ + CALLS.load(Relaxed) as u64, + FREES.load(Relaxed) as u64, + REQUESTED.load(Relaxed) as u64, + ] + } + pub fn snapshot() -> serde_json::Value { + use Ordering::Relaxed; + serde_json::json!({"liveRequestedBytes":LIVE.load(Relaxed),"peakRequestedBytes":PEAK.load(Relaxed),"allocationOrReallocationCalls":CALLS.load(Relaxed),"deallocationCalls":FREES.load(Relaxed),"cumulativeRequestedBytes":REQUESTED.load(Relaxed),"scope":"Process-wide Rust System allocator requests; concurrent threads overlap. Excludes foreign V8/C++/driver allocations, allocator metadata, reserved arenas and fragmentation. Overlaps expanded frame ownership and RSS/PSS; do not sum."}) + } +} +pub(crate) fn snapshot() -> serde_json::Value { + #[cfg(feature = "pipeline-attribution")] + return enabled::snapshot(); + #[cfg(not(feature = "pipeline-attribution"))] + serde_json::Value::Null +} + +pub(crate) fn counts() -> Option<[u64; 3]> { + #[cfg(feature = "pipeline-attribution")] + return Some(enabled::counts()); + #[cfg(not(feature = "pipeline-attribution"))] + None +} diff --git a/crates/twinkle/src/ui/tree/tests.rs b/crates/twinkle/src/ui/tree/tests.rs index 242c1e1..022565f 100644 --- a/crates/twinkle/src/ui/tree/tests.rs +++ b/crates/twinkle/src/ui/tree/tests.rs @@ -8799,3 +8799,227 @@ fn layout_child_intrinsic_hint_never_executes_during_measurement() { let _ = UiFrame::layout(element, Rect::new(0.0, 0.0, 320.0, 180.0)); assert_eq!(builds.get(), 1); } + +#[test] +fn generated_layout_descendants_refresh_without_accumulating_old_interactions() { + let counter = std::rc::Rc::new(std::cell::Cell::new(0usize)); + let build = || { + let counter = counter.clone(); + Column::new().id("outer").child( + Grid::fixed(1).id("grid").child( + Container::new() + .id("dynamic") + .width(160.0) + .height(90.0) + .into_element() + .with_layout_child_preferred(Size::new(160.0, 90.0), move |_| { + Button::new(counter.get(), format!("Tick {}", counter.get())) + .id(format!("generated-{}", counter.get())) + .into_element() + }), + ), + ) + }; + let bounds = Rect::new(0.0, 0.0, 320.0, 180.0); + let mut state = UiStateStore::default(); + let mut frame = UiFrame::resolve(build(), FrameRequest::new(bounds, &mut state)); + let initial = frame.resource_diagnostics(); + for tick in 1..30 { + counter.set(tick); + frame = UiFrame::resolve_against_reusing( + build(), + FrameRequest::new(bounds, &mut state), + &mut frame, + ); + let resources = frame.resource_diagnostics(); + assert_eq!( + resources.hit_target_count, initial.hit_target_count, + "hit records grew at tick {tick}" + ); + assert_eq!( + resources.message_binding_count, initial.message_binding_count, + "message records grew at tick {tick}" + ); + assert!( + frame + .commands + .iter() + .any(|p| matches!(p,PaintCommand::Text{text,..} if text==&format!("Tick {tick}"))) + ); + assert!( + !frame + .commands + .iter() + .any(|p| matches!(p,PaintCommand::Text{text,..} if text=="Tick 0")) + ); + } +} + +#[test] +fn boundary_identity_complete_frames_match_textual_and_whole_overlay_oracles() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + MEASUREMENT_ORACLE_MODE.set(0); + } + } + let _reset = Reset; + let fixture = |reverse: bool| { + let mut children = (0..12) + .map(|index| { + Container::::new() + .id(format!("branch-{index}")) + .message(TestMessage::Named("active")) + .interaction_paints([ + InteractionPaint::default(), + InteractionPaint { + width: Some(Length::Px(96.0 + index as f32)), + height: Some(Length::Px(48.0)), + ..Default::default() + }, + InteractionPaint::default(), + ]) + .child(Column::new().child(Text::new(format!("content {index}")).wrap(true))) + .into_element() + }) + .collect::>(); + if reverse { + children.reverse(); + } + Row::new().children(children).into_element() + }; + let bounds = Rect::new(0.0, 0.0, 800.0, 720.0); + let mut histories = (0..3) + .map(|_| (UiStateStore::default(), None)) + .collect::>(); + for (step, pressed) in [false, true, true, false, true].into_iter().enumerate() { + let mut frames = Vec::new(); + for (mode, (state, previous)) in histories.iter_mut().enumerate() { + MEASUREMENT_ORACLE_MODE.set(mode as u8); + state.set_pressed(pressed.then(|| UiId::from("root/branch-0"))); + let request = FrameRequest::new(bounds, state); + let frame = match previous.as_ref() { + Some(previous) => { + UiFrame::resolve_against(fixture(step % 2 == 1), request, previous) + } + None => UiFrame::resolve(fixture(false), request), + }; + frames.push(frame); + } + MEASUREMENT_ORACLE_MODE.set(0); + let mut cold_state = UiStateStore::default(); + cold_state.set_pressed(pressed.then(|| UiId::from("root/branch-0"))); + let cold = UiFrame::resolve( + fixture(step % 2 == 1), + FrameRequest::new(bounds, &mut cold_state), + ); + assert_eq!( + frames[0].commands(), + cold.commands(), + "cold commands at step {step}" + ); + assert_eq!( + frames[0].resolved_layout(), + cold.resolved_layout(), + "cold layout at step {step}" + ); + let hit_signature = |frame: &UiFrame| { + frame + .hits + .iter() + .map(|hit| { + ( + hit.id.clone(), + hit.rect, + hit.target_bounds, + hit.value_bounds, + hit.message.clone(), + ) + }) + .collect::>() + }; + assert_eq!(hit_signature(&frames[0]), hit_signature(&cold)); + for oracle in &frames[1..] { + assert_eq!(hit_signature(&frames[0]), hit_signature(oracle)); + assert_eq!(frames[0].commands(), oracle.commands()); + assert_eq!(frames[0].resolved_layout(), oracle.resolved_layout()); + assert_eq!( + frames[0].accessibility_nodes(), + oracle.accessibility_nodes() + ); + } + for ((_, previous), frame) in histories.iter_mut().zip(frames) { + *previous = Some(frame); + } + } +} + +#[test] +#[ignore = "release-profile sparse active-overlay admission workload"] +fn retained_sparse_active_overlay_identity_release_workload() { + const WARMUP: usize = 10; + const ITERATIONS: usize = 200; + const BRANCHES: usize = 200; + + fn fixture() -> Element { + let branches = (0..BRANCHES).map(|index| { + let content = Column::new() + .child(Text::new(format!("branch {index} primary measurement text")).wrap(true)) + .child(Text::new("secondary measurement text").wrap(true)); + if index == 0 { + Container::new() + .id(format!("branch-{index}")) + .message(TestMessage::Named("active")) + .interaction_paints([ + InteractionPaint::default(), + InteractionPaint { + width: Some(Length::Px(96.0)), + height: Some(Length::Px(48.0)), + ..InteractionPaint::default() + }, + InteractionPaint::default(), + ]) + .child(content) + .into_element() + } else { + Container::new() + .id(format!("branch-{index}")) + .child(content) + .into_element() + } + }); + Row::new().gap(2.0).children(branches).into_element() + } + + let bounds = Rect::new(0.0, 0.0, 800.0, 720.0); + let mut state = UiStateStore::default(); + state.set_pressed(Some(UiId::from("root/branch-0"))); + let mut previous = UiFrame::layout_with_state(fixture(), bounds, &mut state); + for _ in 0..WARMUP { + previous = UiFrame::resolve_against_reusing( + fixture(), + FrameRequest::new(bounds, &mut state), + &mut previous, + ); + } + let mut samples = Vec::with_capacity(ITERATIONS); + for _ in 0..ITERATIONS { + let started = std::time::Instant::now(); + let frame = UiFrame::resolve_against_reusing( + fixture(), + FrameRequest::new(bounds, &mut state), + &mut previous, + ); + samples.push(started.elapsed().as_secs_f64() * 1_000_000.0); + std::hint::black_box(frame.resolved_layout().nodes.len()); + previous = frame; + } + samples.sort_by(f64::total_cmp); + let percentile = |percent: usize| samples[(ITERATIONS * percent).div_ceil(100) - 1]; + eprintln!( + "retained_sparse_active_overlay branches={BRANCHES} warmup={WARMUP} iterations={ITERATIONS} p50_us={:.3} p95_us={:.3} total_us={:.3}", + percentile(50), + percentile(95), + samples.iter().sum::() + ); +} diff --git a/crates/twinkle/src/wayland_presentation.rs b/crates/twinkle/src/wayland_presentation.rs new file mode 100644 index 0000000..83dad2e --- /dev/null +++ b/crates/twinkle/src/wayland_presentation.rs @@ -0,0 +1,214 @@ +//! Optional guest queue on winit's Wayland connection. It neither owns the display +//! nor dispatches winit's queue. Requests are attached before the present commit. +use crate::{NativeFrameCorrelation, NativeFramePresentation}; +use std::{ + ffi::c_void, + sync::Arc, + time::{Duration, Instant}, +}; +use wayland_client::{ + Connection, Dispatch, EventQueue, Proxy, QueueHandle, + backend::{Backend, ObjectId}, + protocol::{wl_registry, wl_surface}, +}; +use wayland_protocols::wp::presentation_time::client::{wp_presentation, wp_presentation_feedback}; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Token { + pub correlation: Option, + pub frame_generation: u64, +} +#[derive(Default)] +struct State { + presentation: Option, + clock_id: Option, + completed: Vec, + outstanding: usize, +} +pub(crate) struct Feedback { + queue: EventQueue, + state: State, + surface: wl_surface::WlSurface, + // Keep foreign owners alive even when exceptional teardown skips runtime stop. + _window: Arc, + _display: winit::event_loop::OwnedDisplayHandle, +} +impl Feedback { + /// SAFETY: both pointers must be winit-owned live objects from the same + /// display, with the passed owner handles corresponding to those pointers. + /// Owner handles are retained until the guest queue has been destroyed. + pub unsafe fn new( + display: *mut c_void, + surface: *mut c_void, + display_owner: winit::event_loop::OwnedDisplayHandle, + window_owner: Arc, + ) -> Result { + let backend = unsafe { Backend::from_foreign_display(display.cast()) }; + let connection = Connection::from_backend(backend); + let id = unsafe { ObjectId::from_ptr(wl_surface::WlSurface::interface(), surface.cast()) } + .map_err(|e| e.to_string())?; + let surface = wl_surface::WlSurface::from_id(&connection, id).map_err(|e| e.to_string())?; + let mut queue = connection.new_event_queue::(); + let qh = queue.handle(); + let registry = connection.display().get_registry(&qh, ()); + let mut state = State::default(); + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + // Registry is no longer needed; wl_registry has no protocol destructor. + drop(registry); + Ok(Self { + queue, + state, + surface, + _window: window_owner, + _display: display_owner, + }) + } + pub fn supported(&self) -> bool { + self.state.presentation.is_some() + && self.state.clock_id == Some(libc::CLOCK_MONOTONIC as u32) + } + pub fn request(&mut self, token: Token) -> Result { + if !self.supported() { + return Ok(false); + } + if self.state.outstanding >= 128 { + return Err("Wayland presentation feedback exceeds 128 outstanding frames".into()); + } + let presentation = self.state.presentation.as_ref().unwrap(); + presentation.feedback(&self.surface, &self.queue.handle(), token); + self.state.outstanding += 1; + self.queue.flush().map_err(|e| e.to_string())?; + Ok(true) + } + pub fn dispatch(&mut self) -> Result, String> { + self.queue + .dispatch_pending(&mut self.state) + .map_err(|e| e.to_string())?; + Ok(std::mem::take(&mut self.state.completed)) + } +} +impl Drop for Feedback { + fn drop(&mut self) { + if let Some(p) = self.state.presentation.take() { + p.destroy(); + } + let _ = self.queue.flush(); + } +} +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + { + if interface == "wp_presentation" { + state.presentation = Some(registry.bind(name, version.min(2), qh, ())); + } + } + } +} +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wp_presentation::WpPresentation, + event: wp_presentation::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wp_presentation::Event::ClockId { clk_id } = event { + state.clock_id = Some(clk_id); + } + } +} +fn clock_timestamp(seconds: u64, nanos: u32) -> Option { + if nanos >= 1_000_000_000 { + return None; + } + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: writable initialized timespec; no pointer escapes. + if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) } != 0 { + return None; + } + let now = Instant::now(); + let clock = Duration::new( + u64::try_from(ts.tv_sec).ok()?, + u32::try_from(ts.tv_nsec).ok()?, + ); + map_timestamp(now, clock, Duration::new(seconds, nanos)) +} +fn map_timestamp(now: Instant, clock: Duration, stamp: Duration) -> Option { + now.checked_sub(clock.checked_sub(stamp)?) +} +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wp_presentation_feedback::WpPresentationFeedback, + event: wp_presentation_feedback::Event, + token: &Token, + _: &Connection, + _: &QueueHandle, + ) { + let result = match event { + wp_presentation_feedback::Event::Presented { + tv_sec_hi, + tv_sec_lo, + tv_nsec, + refresh, + seq_hi, + seq_lo, + .. + } => { + let seconds = ((tv_sec_hi as u64) << 32) | tv_sec_lo as u64; + Some(NativeFramePresentation { + correlation: token.correlation, + frame_generation: token.frame_generation, + presented_at: clock_timestamp(seconds, tv_nsec), + observed_at: Instant::now(), + discarded: false, + refresh_interval: Some(Duration::from_nanos(refresh as u64)), + sequence: Some(((seq_hi as u64) << 32) | seq_lo as u64), + }) + } + wp_presentation_feedback::Event::Discarded => Some(NativeFramePresentation { + correlation: token.correlation, + frame_generation: token.frame_generation, + presented_at: None, + observed_at: Instant::now(), + discarded: true, + refresh_interval: None, + sequence: None, + }), + _ => None, + }; + if let Some(result) = result { + state.outstanding = state.outstanding.saturating_sub(1); + state.completed.push(result); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn clock_mapping_uses_reported_presentation_not_callback_time() { + let now = Instant::now(); + let mapped = + map_timestamp(now, Duration::from_secs(20), Duration::from_millis(19900)).unwrap(); + assert_eq!(now.duration_since(mapped), Duration::from_millis(100)); + assert!(map_timestamp(now, Duration::from_secs(20), Duration::from_secs(21)).is_none()); + } +} diff --git a/docs/chart-performance.md b/docs/chart-performance.md index d6266d1..3566b3b 100644 --- a/docs/chart-performance.md +++ b/docs/chart-performance.md @@ -132,3 +132,68 @@ GPU execution-completion and compositor display timestamps remain unavailable on the current window-presenter boundary and are not inferred from submission time. This limits claims about physical display latency, but no longer leaves raster agreement or the specified CPU-side warm-frame budget unmeasured. + +## Application-owned benchmark attribution + +Applications may publish a payload-free `NativeFrameCorrelation` through +`Application::take_frame_correlation`. The standalone native runtime now calls +`Application::native_frame_submitted` after a successful GPU presenter call or +software buffer present. `NativeFrameSubmission` carries the correlation captured +with that frame, its native frame generation, an `Instant` acknowledgement, +actual backend, scale factor, and physical surface size. Software raster results +that are dirty, stale, or mismatched in size do not produce an acknowledgement. +Applications can use missing correlation generations to detect coalesced frames; +this callback does not disable the runtime's coalescing policy. + +This evidence ends at CPU submission. GPU completion and compositor presentation +remain unavailable; neither refresh cadence nor this callback proves display. + +Explicit heatmap color domains now remain fixed across snapshot extrema changes. +Explicit bubble size domains map normalized value to marker area (10 logical-pixel +maximum radius), with a 2-pixel minimum radius so zero-size entities remain +inspectable. Implicit domains retain their existing maximum-relative behavior. + +### Optional Wayland compositor feedback + +Enable `twinkle-ui/wayland-presentation` on Linux to attach a guest event queue to +the standalone winit-owned Wayland display and surface. Where the compositor +advertises `wp_presentation` with `CLOCK_MONOTONIC`, the runtime requests feedback +before each native present commit and delivers `NativeFramePresentation` through +`Application::native_frame_presented`. It also announces capability through +`native_presentation_supported`. Feedback contains the captured correlation, +actual reported presentation time mapped to Rust `Instant`, callback observation +time, refresh interval, display sequence, and explicit discarded-frame state. + +Timestamp mapping samples Linux CLOCK_MONOTONIC and Instant back to back; its +uncertainty is the sampling overhead, not event-loop callback delay. Future or +invalid timestamps remain unavailable. Unsupported clocks and non-Wayland +surfaces remain unsupported. The guest queue is bounded to 128 outstanding +feedback objects, never owns or closes winit's display, and is dropped before +its surface/window owner. This feature provides compositor display evidence, +not GPU execution timestamps. Earlier uninstrumented measurements in this +file retain their original unavailable-presentation boundary. + +Categorical axis guides now use the admitted category labels, preserving source +order and thinning labels to the requested tick budget. Guide measurement and +painting use the same labels; linear numeric ticks are computed from the domain +in f64 so label values do not acquire f32 mapping noise. Initial native frames +capture their application correlation, including zero-warm-up benchmarks. +`NativeFrameSubmission::present_call_duration` reports CPU time inside the native +presenter call, independently of end-to-end deadline latency. + +Controlled line/area legend toggles preserve the palette slots of hidden series, +so remaining series keep their original colors and agree with the legend. State +updates still require an advanced retained content revision from the application. + +Native submission timestamps are captured immediately when the present call +returns, before attribution output. The Wayland guest queue retains cloned +window/display owners until its own proxies and queue are gone, including +exceptional teardown paths. + +### Generated chart subtree invalidation + +A long-running consumer exposed stale output reuse above `with_layout_child` +callbacks. Such nodes now mark their entire ancestry unsafe for bulk resolved +and independent output reuse. A regression repeatedly changes generated content +and identities, asserting current paint plus bounded hit/message counts. This +preserves responsive generated chart content instead of reusing old descendants. diff --git a/scripts/check-doc-coverage.sh b/scripts/check-doc-coverage.sh index c235b08..a3bcc57 100755 --- a/scripts/check-doc-coverage.sh +++ b/scripts/check-doc-coverage.sh @@ -7,7 +7,7 @@ cd "$repository_root" # Force the lint through the temporary module-level legacy allowances. This is a ratchet, not a # claim that the remaining warnings are acceptable documentation. Lower the bound whenever public # items receive useful rustdoc; never raise it to admit a regression. -maximum_missing_docs=2234 +maximum_missing_docs=2223 report="$(mktemp)" trap 'rm -f "$report"' EXIT diff --git a/scripts/run_compound_measurement_pairs.py b/scripts/run_compound_measurement_pairs.py new file mode 100644 index 0000000..c60892d --- /dev/null +++ b/scripts/run_compound_measurement_pairs.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Serialized production-library admission for compound measurement classification.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--before', type=Path, required=True) + parser.add_argument('--after', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--suite', choices=['primary', 'guards'], default='primary') + parser.add_argument('--fixture', action='append', help='Restrict the selected suite to family-mode-scenario; repeatable') + args = parser.parse_args() + args.output.mkdir(exist_ok=False) + binaries = {'before': args.before.resolve(), 'after': args.after.resolve()} + fixtures = [(family, mode, 'paint') for family in ['slider', 'dropdown'] for mode in ['cold', 'retained']] + if args.suite == 'guards': + fixtures = [('dropdown', mode, scenario) for scenario in ['font', 'inline'] for mode in ['cold', 'retained']] + fixtures += [('dropdown', 'retained', 'many-options')] + fixtures += [(family, 'retained', scenario) for scenario in ['dense', 'tiny'] for family in ['slider', 'dropdown']] + fixtures += [(family, 'cold', 'structural') for family in ['slider', 'dropdown']] + if args.fixture: + selected = set(args.fixture) + available = {'-'.join(f) for f in fixtures} + if not selected <= available: + parser.error(f'Unknown fixtures: {selected - available}') + fixtures = [f for f in fixtures if '-'.join(f) in selected] + root = Path(__file__).resolve().parents[1] + source = root / 'crates/twinkle-workbench/src/bin/twinkle_compound_admission.rs' + protocol = {'status': 'running', 'suite': args.suite, 'pairs': 5, 'iterations': 200, 'warmups': 10, 'binarySha256': {v: hashlib.sha256(p.read_bytes()).hexdigest() for v, p in binaries.items()}, 'fixtureSourceSha256': hashlib.sha256(source.read_bytes()).hexdigest(), 'candidateRevision': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip(), 'candidateTrackedPatchSha256': hashlib.sha256(subprocess.check_output(['git', 'diff', '--binary', 'HEAD'], cwd=root)).hexdigest(), 'scope': 'Ordinary UI dependency (cfg(test) disabled). Separate normal timing and attribution builds. Five serial alternating pairs.', 'runs': []} + env = os.environ.copy() + for key in ['TWINKLE_PIPELINE_TRACE', 'TWINKLE_JSX_TRACE']: + env.pop(key, None) + try: + for family, mode, scenario in fixtures: + fixture = f'{family}-{mode}-{scenario}' + for pair in range(1, 6): + for variant in ['before', 'after'] if pair % 2 else ['after', 'before']: + command = [str(binaries[variant]), '--family', family, '--mode', mode, '--scenario', scenario, '--iterations', '200'] + result = subprocess.run(command, cwd=root, env=env, capture_output=True, text=True) + log = result.stdout + result.stderr + name = f'{fixture}-{pair:02}-{variant}.log' + (args.output / name).write_text(log) + if result.returncode: + raise RuntimeError(f'{name}: {log[-1500:]}') + reports = [json.loads(line.split('=', 1)[1]) for line in log.splitlines() if line.startswith('compound_admission=')] + if len(reports) != 1: + raise ValueError(f'Missing report: {name}') + report = reports[0] + assert report['family'] == family and report['mode'] == mode and (report['scenario'] == scenario) + assert report['iterations'] == 200 and report['warmups'] == 10 + assert not report['harnessCfgTest'] and (not report['allocatorEnabled']) + assert report['activeFrames'] == (0 if scenario == 'structural' else 100) + assert report['nodeCount'] >= (2 if scenario == 'tiny' else 802) + for stage in ['nativeFrame', 'completeUpdate', 'layout']: + assert len(report['rawSamplesUs'][stage]) == 200 + protocol['runs'].append({'fixture': fixture, 'pair': pair, 'variant': variant, 'log': name, 'report': report}) + print(f"{fixture} pair={pair} {variant}: {report['nativeFrame']}", flush=True) + protocol['status'] = 'complete' + finally: + (args.output / 'protocol.json').write_text(json.dumps(protocol, indent=2) + '\n') +if __name__ == '__main__': + main() diff --git a/scripts/run_measurement_boundary_pairs.py b/scripts/run_measurement_boundary_pairs.py new file mode 100644 index 0000000..c1c9475 --- /dev/null +++ b/scripts/run_measurement_boundary_pairs.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Run serialized, alternating release pairs for spec 0021's native fixtures. + +Build and freeze both normal release test executables first. Attribution builds +must run separately. Each executable must contain the same fixture definitions. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--before", type=Path, required=True) + parser.add_argument("--after", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--guards", action="store_true", help="Run the frozen Blocks/rich-form integration executables") + parser.add_argument("--matrix", action="store_true", help="Run the native-frame sparse/tiny/dense/structural fixture executables") + args = parser.parse_args() + if args.guards and args.matrix: + parser.error("Choose either --guards or --matrix") + args.output.mkdir(exist_ok=False) + binaries = {"before": args.before.resolve(), "after": args.after.resolve()} + protocol = { + "status": "running", + "pairs": 5, + "binarySha256": {key: hashlib.sha256(path.read_bytes()).hexdigest() + for key, path in binaries.items()}, + "runs": [], + "candidateRevision": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "candidateTrackedPatchSha256": hashlib.sha256(subprocess.check_output(["git", "diff", "--binary", "HEAD"])).hexdigest(), + "fixtureSourceSha256": hashlib.sha256((Path(__file__).resolve().parents[1] / "crates/twinkle/src/ui/tree/measurement_admission.rs").read_bytes()).hexdigest(), + "scope": "Complete native frame including declaration construction; 801 nodes, 800x720, 10 warmups, 200 samples. No JSX producer/update timing claim.", + } + env = os.environ.copy() + for key in ("TWINKLE_PIPELINE_TRACE", "TWINKLE_JSX_TRACE"): + env.pop(key, None) + fixtures = ["sparse_active_overlay_dependency_elision_release_workload", + "retained_sparse_active_overlay_identity_release_workload"] + if args.guards: + fixtures = ["blocks_shaped_jsx_updates_emit_end_to_end_release_distribution", + "rich_accessible_form_updates_emit_end_to_end_release_distribution"] + protocol["scope"] = "Frozen 200-cell Blocks and eight-field rich form; 10 warmups, 1000 complete updates with native-frame/layout distributions." + if args.matrix: + fixtures = ["sparse-cold", "sparse-retained", "tiny", "dense", "structural"] + protocol["scope"] = "Native-frame fixture matrix; 10 warmups, 200 samples; complete update includes declaration construction. Separate attribution/allocator builds." + try: + for fixture in fixtures: + for pair in range(1, 6): + order = ["before", "after"] if pair % 2 else ["after", "before"] + for variant in order: + if args.matrix: + env["TWINKLE_MEASUREMENT_CASE"] = fixture + test = "measurement_boundary_native_frame_release_admission" if args.matrix else fixture + result = subprocess.run( + [str(binaries[variant]), test, "--ignored", "--nocapture", "--test-threads=1"], + env=env, capture_output=True, text=True, check=True) + log = result.stdout + result.stderr + name = f"{fixture}-{pair:02}-{variant}.log" + (args.output / name).write_text(log) + if "test result: ok. 1 passed" not in log: + raise ValueError(f"Missing successful fixture: {name}") + run = {"fixture": fixture, "pair": pair, "variant": variant, "log": name} + if args.matrix: + reports = [json.loads(line.split("measurement_boundary_admission=", 1)[1]) + for line in log.splitlines() if line.startswith("measurement_boundary_admission=")] + if len(reports) != 1 or reports[0]["case"] != fixture: + raise ValueError(f"Missing native report: {name}") + run["report"] = reports[0] + print(f"{fixture} pair={pair} {variant}: {reports[0]['nativeFrame']}", flush=True) + elif args.guards: + reports = [json.loads(line.split("twinkle_release_admission=", 1)[1]) + for line in log.splitlines() if line.startswith("twinkle_release_admission=")] + if len(reports) != 1: + raise ValueError(f"Missing frozen report: {name}") + run["report"] = reports[0] + print(f"{fixture} pair={pair} {variant}: complete", flush=True) + else: + metrics = re.search(r"p50_us=([\d.]+) p95_us=([\d.]+) total_us=([\d.]+)", log) + if metrics is None: + raise ValueError(f"Missing distribution: {name}") + run.update(zip(("p50_us", "p95_us", "total_us"), map(float, metrics.groups()))) + print(f"{fixture} pair={pair} {variant}: {metrics.group(0)}", flush=True) + protocol["runs"].append(run) + protocol["status"] = "complete" + finally: + (args.output / "protocol.json").write_text(json.dumps(protocol, indent=2) + "\n") + + +if __name__ == "__main__": + main()