Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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);
    }
}