facett-demo/tests/lineage_drag_at_scale.rs
//! **`lineage` vs `Graphs` at 100 000 nodes, frame by frame, in the real demo frame**
//! (lane M, 2026-10-02).
//!
//! Lane L measured `lineage` under vsync on loki's RTX 5080 (56.8 / 58.3 fps dragging,
//! rAF p50 17.1–17.6 ms) while `Graphs` — the same `GraphNav`, the same layout lane, the
//! same resident cloud — held 60.2. This drives the demo exactly as the wasm page does
//! per frame (`DemoState::draw_app`, then the WHOLE demo state serialised for
//! `window.__facett_state()`), with the probe's drag (press at 0.35 / 0.6 of the canvas,
//! a 3 px sawtooth), and prints where the time goes. The installed-renderer gate is
//! opened without a device (`cloud-test-gate`, as in `four_tabs_at_scale`): the paint
//! callbacks land in the frame's shapes and never run.
//!
//! ```bash
//! cargo test --release -p facett-demo --test lineage_drag_at_scale -- --nocapture
//! ```
use std::time::{Duration, Instant};
use egui::{Pos2, Vec2};
use facett::core::render::gpu::graphcloud::cloud;
use facett_demo::{DemoState, graphgen};
use facett_graphnav::{LayoutKind, Msg};
fn n() -> usize {
if cfg!(debug_assertions) { 20_000 } else { 100_000 }
}
const SIZE: Vec2 = Vec2::new(1600.0, 1000.0);
#[derive(Default, Clone, Copy)]
struct Cost {
draw: Duration,
state: Duration,
}
/// How the page publishes its state each frame.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Publish {
/// Before lane M: every tab's state, every frame (`DemoState::state_json`).
EveryTab,
/// Lane M: the tab on screen + one inactive tab in turn (`state_json_rolling`).
Rolling,
}
thread_local! {
static CACHE: std::cell::RefCell<facett::core::DeckStateCache> = std::cell::RefCell::new(Default::default());
static MODE: std::cell::Cell<Publish> = const { std::cell::Cell::new(Publish::Rolling) };
}
/// One page frame: the demo's draw, then its state serialised (`wasm_state::publish`).
fn frame(ctx: &egui::Context, demo: &mut DemoState, events: Vec<egui::Event>) -> Cost {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, SIZE)),
events,
..Default::default()
};
let t = Instant::now();
ctx.run_ui(input, |ui| demo.draw_app(ui)).textures_delta.clear();
let draw = t.elapsed();
let t = Instant::now();
let _ = match MODE.with(std::cell::Cell::get) {
Publish::EveryTab => serde_json::to_string(&demo.state_json()).unwrap_or_default(),
Publish::Rolling => CACHE.with(|c| demo.publish_rolling(&mut c.borrow_mut())),
};
Cost { draw, state: t.elapsed() }
}
fn press(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// The pages probe's drag, `steps` frames. Returns the p50 of draw, state and both.
fn drag(ctx: &egui::Context, demo: &mut DemoState, steps: usize) -> (Duration, Duration, Duration) {
let (x0, x1, y) = (SIZE.x * 0.35, SIZE.x * 0.65, SIZE.y * 0.6);
let mut x = x0;
frame(ctx, demo, vec![egui::Event::PointerMoved(Pos2::new(x, y))]);
frame(ctx, demo, vec![press(Pos2::new(x, y), true)]);
let mut costs = Vec::with_capacity(steps);
for _ in 0..steps {
x += 3.0;
if x > x1 {
x = x0;
}
costs.push(frame(ctx, demo, vec![egui::Event::PointerMoved(Pos2::new(x, y))]));
}
frame(ctx, demo, vec![press(Pos2::new(x, y), false)]);
let p50 = |mut v: Vec<Duration>| {
v.sort_unstable();
v[v.len() / 2]
};
(
p50(costs.iter().map(|c| c.draw).collect()),
p50(costs.iter().map(|c| c.state).collect()),
p50(costs.iter().map(|c| c.draw + c.state).collect()),
)
}
/// The demo on `tab` at `community:<n>:1`, its navigator on a cheap CPU placement (no
/// host device here; in the browser both tabs take the GPU layout job).
fn demo_on(tab: &str, n: usize) -> DemoState {
let mut demo = DemoState::new();
demo.attract_enabled = false;
demo.show_about = false;
assert!(demo.set_active_tab(tab));
assert!(demo.apply_gen_link(Some(&format!("community:{n}:1"))));
demo
}
fn set_grid(demo: &mut DemoState, tab: &str) {
let ok = match tab {
"Graphs" => demo
.deck
.facet_mut::<graphgen::GenTab<facett_demo::vg_lab_tab::VgLabTab>>(tab)
.map(|t| t.inner_mut().nav_update(Msg::SetLayout(LayoutKind::Grid))),
_ => demo
.deck
.facet_mut::<graphgen::GenTab<facett_lineage::LineageView>>(tab)
.map(|t| t.inner_mut().update(Msg::SetLayout(LayoutKind::Grid))),
};
assert!(ok.is_some(), "{tab} is a GenTab");
}
#[test]
fn lineage_and_graphs_drag_frames_cost_the_same() {
cloud::assume_renderer_installed_for_tests();
let n = n();
let mut rows = Vec::new();
for mode in [Publish::EveryTab, Publish::Rolling] {
MODE.with(|m| m.set(mode));
for tab in ["Graphs", "lineage"] {
CACHE.with(|c| *c.borrow_mut() = Default::default());
let mut demo = demo_on(tab, n);
let ctx = egui::Context::default();
ctx.set_pixels_per_point(1.0);
frame(&ctx, &mut demo, Vec::new()); // the rebuild at 100k
set_grid(&mut demo, tab);
for _ in 0..3 {
frame(&ctx, &mut demo, Vec::new());
}
let s = demo.state_json();
let f = &s["deck"]["facets"][tab];
assert_eq!(f["gen"]["nodes"], n, "{tab} generated: {}", f["gen"]);
assert_eq!(f["gen"]["compact"], true);
let (draw, state, total) = drag(&ctx, &mut demo, 90);
// The drag reached the navigator (the camera moved) and drew no hover.
let after = demo.state_json();
let leg = &after["deck"]["facets"][tab]["legibility"]["frame"];
eprintln!(
"{mode:?} {tab} {n}: drag frame p50 draw {draw:?} + state {state:?} = {total:?} · legibility {leg}"
);
rows.push((mode, tab, state, total));
}
}
let get = |m: Publish, t: &str| rows.iter().find(|r| r.0 == m && r.1 == t).map(|r| (r.2, r.3)).unwrap();
for m in [Publish::EveryTab, Publish::Rolling] {
let (g, l) = (get(m, "Graphs").1, get(m, "lineage").1);
eprintln!("{m:?}: lineage / Graphs drag frame p50 = {:.2}", l.as_secs_f64() / g.as_secs_f64());
}
// The rolling publish pays for the tab on screen, not the whole deck.
for t in ["Graphs", "lineage"] {
let (every, rolling) = (get(Publish::EveryTab, t).0, get(Publish::Rolling, t).0);
assert!(rolling < every, "{t}: rolling state {rolling:?} vs every tab {every:?}");
}
}
/// What each tab's DEMO-DATA state costs per frame when it is NOT the active tab — the
/// page serialises every tab's state every frame, so the lineage run pays for `Graphs`'
/// demo population graph and the Graphs run pays for lineage's six-row fixture.
#[test]
fn the_inactive_tabs_state_costs() {
let reps = 20;
let time = |name: &str, f: &dyn Fn() -> String| {
let _ = f();
let t = Instant::now();
let mut bytes = 0;
for _ in 0..reps {
bytes = f().len();
}
let per = t.elapsed() / reps;
eprintln!("{name}: state_json {per:?} per frame, {bytes} B");
per
};
let graphs = facett_demo::vg_lab_tab::VgLabTab::new("Graphs");
let lineage = facett_lineage::LineageView::demo();
use facett::Facet as _;
let g = time("Graphs (demo data)", &|| serde_json::to_string(&graphs.state_json()).unwrap());
let l = time("lineage (demo data)", &|| serde_json::to_string(&lineage.state_json()).unwrap());
eprintln!("Graphs-demo state − lineage-demo state = {:?}", g.saturating_sub(l));
}
/// Where the page's per-frame state time goes with each tab on screen (rolling publish):
/// the deck fold, and the whole publish (roll-ups + serialisation). Min of 40 frames.
#[test]
fn the_state_publish_breakdown() {
cloud::assume_renderer_installed_for_tests();
let n = n();
for round in 0..2 {
for tab in ["Graphs", "lineage"] {
let mut demo = demo_on(tab, n);
let ctx = egui::Context::default();
ctx.set_pixels_per_point(1.0);
frame(&ctx, &mut demo, Vec::new());
set_grid(&mut demo, tab);
frame(&ctx, &mut demo, Vec::new());
let mut cache = facett::core::DeckStateCache::default();
let _ = demo.publish_rolling(&mut cache);
let (mut deck_t, mut all_t, mut bytes) = (Duration::MAX, Duration::MAX, 0);
for _ in 0..40 {
let t = Instant::now();
let d = demo.deck.state_json_rolling(&mut cache);
deck_t = deck_t.min(t.elapsed());
facett::core::FacetDeck::return_rolling(&mut cache, d);
let t = Instant::now();
bytes = demo.publish_rolling(&mut cache).len();
all_t = all_t.min(t.elapsed());
}
// The tab on screen's own (compact) state.
let own = {
use facett::Facet as _;
let t = Instant::now();
let len = match tab {
"Graphs" => demo
.deck
.facet_mut::<graphgen::GenTab<facett_demo::vg_lab_tab::VgLabTab>>(tab)
.map(|f| f.state_json().to_string().len()),
_ => demo.deck.facet_mut::<graphgen::GenTab<facett_lineage::LineageView>>(tab).map(|f| f.state_json().to_string().len()),
};
(t.elapsed(), len)
};
eprintln!("round {round} {tab}: its own compact state {:?} ({:?} B)", own.0, own.1);
let built = cache.built;
let _ = demo.publish_rolling(&mut cache);
assert_eq!(cache.built - built, 2, "one publish builds the tab on screen + one other");
eprintln!("round {round} {tab}: deck fold {deck_t:?} · whole publish {all_t:?} ({bytes} B)");
}
}
}
#[test]
#[ignore = "diagnostic: per-turn fold cost"]
fn fold_cost_per_turn() {
cloud::assume_renderer_installed_for_tests();
for tab in ["lineage", "Graphs"] {
let mut demo = demo_on(tab, n());
let ctx = egui::Context::default();
frame(&ctx, &mut demo, Vec::new());
set_grid(&mut demo, tab);
frame(&ctx, &mut demo, Vec::new());
let mut cache = facett::core::DeckStateCache::default();
let _ = demo.publish_rolling(&mut cache);
let mut ts = Vec::new();
for _ in 0..162 {
let b = cache.built;
let t = Instant::now();
let d = demo.deck.state_json_rolling(&mut cache);
let e = t.elapsed();
facett::core::FacetDeck::return_rolling(&mut cache, d);
ts.push((e, cache.built - b));
}
{
let titles: Vec<String> = demo.deck.titles().iter().map(|s| s.to_string()).collect();
let n = titles.len();
let active = demo.deck.active();
let mut next = 0usize;
let mut turn = || {
let pick = (0..n).map(|k| (next + k) % n).find(|&i| i != active).unwrap();
next = pick + 1;
pick
};
let _ = turn(); // the warm-up publish
let mut named: Vec<(Duration, &str)> = ts.iter().map(|t| (t.0, titles[turn()].as_str())).collect();
named.sort_by_key(|n| std::cmp::Reverse(n.0));
eprintln!("{tab}: costliest turns {:?}", &named[..12]);
}
let mut s: Vec<Duration> = ts.iter().map(|t| t.0).collect();
s.sort_unstable();
eprintln!("{tab}: fold min {:?} p50 {:?} p90 {:?} max {:?}", s[0], s[81], s[145], s[161]);
let t = Instant::now();
for _ in 0..20 {
let _ = demo.deck.state_json();
}
eprintln!("{tab}: full deck.state_json {:?}", t.elapsed() / 20);
let titles: Vec<String> = demo.deck.titles().iter().map(|s| s.to_string()).collect();
let mut c = facett::core::DeckStateCache::default();
let d = demo.deck.state_json_rolling(&mut c);
let sizes: usize = d["facets"].as_object().unwrap().len();
eprintln!("{tab}: {} titles, {} distinct", titles.len(), sizes);
}
}