From dccbadb3a11722c5ef54cfa8ca27f06658bfd231 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 2 Oct 2026 23:49:23 -0500 Subject: [PATCH 01/19] docs: record module explorer design and implementation plan --- .../2026-10-02-module-dependency-explorer.md | 343 ++++++++++++++++++ ...10-02-module-dependency-explorer-design.md | 265 ++++++++++++++ 2 files changed, 608 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-02-module-dependency-explorer.md create mode 100644 docs/superpowers/specs/2026-10-02-module-dependency-explorer-design.md diff --git a/docs/superpowers/plans/2026-10-02-module-dependency-explorer.md b/docs/superpowers/plans/2026-10-02-module-dependency-explorer.md new file mode 100644 index 0000000..af1291e --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-module-dependency-explorer.md @@ -0,0 +1,343 @@ +# Module Dependency Explorer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for native execution, or superpowers:subagent-driven-development if the user selects parallel implementation and review. Steps use checkbox syntax for tracking. + +**Goal:** Make Mathlib module imports and dependents understandable through exact selection, directed expansion, a bounded 2D view, useful spatial controls, and a verified pull request. + +**Architecture:** Preserve attributes and actual hub identity in the existing scene. A Bevy-free dependency index implements directed queries; dedicated explorer state, UI, and canvas modules consume it. Spatial rendering uses a shared visibility policy and runs only in spatial mode. + +**Tech Stack:** Rust 2024, hyper-viz, Bevy 0.18, bevy_egui 0.39, serde, Python standard-library exporter tests. + +**Spec:** `docs/superpowers/specs/2026-10-02-module-dependency-explorer-design.md`. + +## Global Constraints + +- The wire-format version stays `hypergraph.v1`. +- Session fields for the explorer are additive and defaulted under `hyperviz.session.v1`. +- Default scope: one hop in each direction; initial visible budget 200, maximum 1,000; hop controls one through six. +- Every arrow means "the source module imports the target module". +- Filters initially include all categories and never remove the center. +- Traversal stops at filtered vertices; omitted and filtered counts are disclosed. +- Centroid attraction positive range `0.000001–0.1`; gravity positive range `0.00001–0.1`; both support zero/off and numeric entry. +- Repulsion `1–100000`; dt `0.01–1`; damping `0.5–0.99`; hull opacity `0–0.6`; line opacity `0–1`. +- Large-graph spatial preset: gravity `0.001`, centroid attraction `0.0005`, deterministic subject initialization, global hulls disabled. +- Simulation budget 8 ms/frame, with at least one step while running and displayed actual step count/duration. +- Keep the declared Rust floor at 1.89; use an installed compatible toolchain for the lockfile without unrelated dependency changes. +- Work in the current managed worktree. No merge or deployment. The user's latest instruction explicitly authorizes creating and publishing the PR branch and opening a PR. + +## Review Focus + +- A scene reload reorders vertices or removes the center: restore IDs safely, invalidate direction/search caches, and show a missing-center state (Tasks 1 and 3). +- A malformed direction refers to a hub, missing vertex, non-member, or higher-arity group: warn and retain generic inspection; never infer a directed import (Tasks 1 and 2). +- Filtered intermediate vertices, cycles, self-loops, and duplicate imports: queries terminate, distinguish excluded paths, and count modules once (Task 2). +- A restored session has out-of-range depths/budgets, nonfinite canvas values, or stale history IDs: normalize before rendering (Task 3). +- Mode switches, disabled hulls, line budgets, and frozen geometry: do not leave invisible pick targets, background simulation, or needless topology rebuilds (Tasks 4 and 5). + +## Execution and workspace + +Recommended execution is native: the implementer works through the six tasks in order, then one independent reviewer examines the complete diff before the PR. The scene, focus, and visibility changes share interfaces; parallel edits would need extra coordination. + +Use the current detached managed worktree. Verify it with `git rev-parse --git-dir --git-common-dir` and `git rev-parse --show-superproject-working-tree`. Create `codex/module-dependency-explorer` before commits using the host's supported Git controls. If the branch name exists, inspect it before choosing a new suffix; never reset an existing branch. + +Create this plan's ledger through the executing-plans workspace script. Preserve completed tasks and record any spec/plan rulings. Build artifacts stay under ignored `target/` or a task-specific temporary directory. Never write to the primary checkout's target directory to reuse its cache. + +Baseline commands: + +```sh +cargo +1.96.0 test --workspace --offline +python3 -m unittest discover -s scripts -p 'test_*.py' +``` + +Expected: the existing suites pass. If dependency/toolchain or runtime restrictions prevent a command, establish the concrete cause, use an installed compatible alternative when available, and preserve the limitation in the ledger. + +--- + +## Task 1: Preserve direction and fix hubless projections + +**Files:** `crates/hyper-viz/src/scene.rs`, `project.rs`, `semantics.rs`; all hub consumers in `crates/hyper-viz-bevy/src/{animation,focus,graph,hyperedge_hull,interaction,ui}.rs`; `crates/hyper-viz/README.md`. + +**Interfaces:** + +```rust +// SceneNode, SceneHyperedge, and SceneMeta gain defaulted attrs maps. +pub attrs: serde_json::Map; +// SceneHyperedge gains truthful hub identity. +pub hub_index: Option; +// Shared generic visibility helper, used by focus/labels/hulls/picking. +pub fn hyperedge_in_scope( + scene: &HypergraphScene, + hyperedge_index: usize, + nodes: Option<&std::collections::HashSet>, + hyperedges: Option<&std::collections::HashSet>, +) -> bool; +``` + +- [ ] Write the regression below in `scene.rs`, then add projection tests for preserved graph/node/edge attributes and legacy scene JSON without attributes. The realistic mutations are fabricating hub zero and dropping direction/path metadata. + +```rust +#[test] +fn star_first_vertex_does_not_focus_unrelated_groups() { + let graph = Hypergraph::new() + .vertex("a", "A", "module") + .vertex("b", "B", "module") + .vertex("c", "C", "module") + .vertex("d", "D", "module") + .hyperedge("ab", ["a", "b"], "AB") + .hyperedge("cd", ["c", "d"], "CD"); + let scene = graph.project(Projection::StarCentroid); + let actual = neighborhood(&scene, [0]); + assert_eq!(actual, std::collections::HashSet::from([0, 1])); +} +``` + +- [ ] Run `cargo +1.96.0 test -p hyper-viz star_first_vertex_does_not_focus_unrelated_groups --offline`. Expected before the fix: four vertices, assertion failure. +- [ ] Copy attributes in every projection. Use `Some(hub_index)` only for bipartite hubs and `None` for star/clique. In `neighborhood`, test/insert the hub only when it exists. Include members through incident membership independently of hub identity. + +```rust +let hits_hub = he.hub_index.is_some_and(|hub| seeds.contains(&hub)); +let hits_member = he.member_indices.iter().any(|i| seeds.contains(i)); +if hits_hub || hits_member { + out.extend(he.hub_index); + out.extend(he.member_indices.iter().copied()); +} +``` + +- [ ] Audit all `hub_index` consumers. Only attach hub status to a real hub; only seed/highlight a hub when `Some`; use `hyperedge_in_scope` for hubless visibility. Extend `FocusScope` with an optional explicit hyperedge set, defaulting old scopes to membership-based checks. +- [ ] Make scene equivalence compare retained attributes and directed relation metadata as well as stable member IDs. Add a reload test changing only source/target attributes and one changing only a source path; both must invalidate caches. +- [ ] Run `cargo +1.96.0 test --workspace --offline`. Expected: all tests pass, including existing generic projections and sessions. Document the Rust scene struct-literal changes and legacy JSON defaults. Commit this task. + +## Task 2: Implement directed dependency queries and module categories + +**Files:** create `crates/hyper-viz/src/dependency.rs` and `module.rs`; modify `lib.rs` and projection warning generation. + +**Consumes:** retained scene attributes and optional hubs from Task 1. + +**Produces:** + +```rust +pub enum TraversalDepth { Off, Hops(u8), Transitive } +pub struct ScopeOptions { + pub imports: TraversalDepth, + pub dependents: TraversalDepth, + pub budget: usize, +} +pub struct DependencyScope { + pub nodes: Vec, + pub edges: Vec<(usize, usize)>, + pub import_depths: std::collections::HashMap, + pub dependent_depths: std::collections::HashMap, + pub total_reachable: usize, + pub omitted: usize, +} +pub struct DirectedImport { + pub source: usize, + pub target: usize, + pub hyperedge_indices: Vec, +} +pub struct DependencyIndex { + ids: std::collections::HashMap, + node_ids: Vec, + outgoing: Vec>, + incoming: Vec>, + relations: Vec, +} +impl DependencyIndex { + pub fn new(scene: &HypergraphScene) -> Self; + pub fn node_index(&self, id: &str) -> Option; + pub fn imports(&self, node: usize) -> &[usize]; + pub fn dependents(&self, node: usize) -> &[usize]; + pub fn scope(&self, center: usize, options: &ScopeOptions, allowed: &[bool]) -> DependencyScope; + pub fn expand(&self, center: usize, previous: &DependencyScope, node: usize, options: &ScopeOptions, allowed: &[bool]) -> DependencyScope; + pub fn shortest_path(&self, source: usize, target: usize, allowed: &[bool]) -> Option>; + pub fn relations(&self) -> &[DirectedImport]; +} +pub struct ModuleCategory { + pub umbrella: bool, + pub test: bool, + pub tactic: bool, + pub external: bool, +} +pub fn module_categories(scene: &HypergraphScene) -> Vec; +``` + +- [ ] Add a hand-built fixture with directed imports `a→b`, `b→c`, `d→a`, `x→d`, and a group `[a,b,c,d,x,z]`. Construct each import as an arity-two edge with literal source/target attributes. Expected scope at one hop: `{a,b,d}`; two-hop imports: `{a,b,c,d}`; two-hop dependents: `{a,b,d,x}`. The group must never add `z`. +- [ ] Add tests for `a→b→a`, a duplicate `a→b`, self-loop `a→a`, empty scene, invalid center, bad endpoints, filtered `b`, and deterministic budget truncation. For `a→b→c`, the shortest path is `[a,b,c]`; from `c` to `a` it is absent; `a` to itself is `[a]`. Filtering `b` blocks that path. +- [ ] Run `cargo +1.96.0 test -p hyper-viz dependency --offline`. Expected: new API is missing, or desired traversal behavior fails. +- [ ] Validate imports using existing vertex IDs, arity, membership, and `kind == "import"`. Keep malformed edges in the scene and emit warnings. Build deduplicated outgoing/incoming adjacency sorted by stable ID. Retain duplicate source edge identities in `DirectedImport`. +- [ ] Implement BFS with visited sets. Traverse each direction independently; union nodes once, retain the center, order by minimum hop distance then ID, and apply budget after traversal. Return induced directed edges only between displayed vertices. Preserve existing scope and center during one-hop expansion. + +```rust +fn traverse(adjacency: &[Vec], source: usize, depth_limit: Option, allowed: &[bool]) -> std::collections::HashMap { + if source >= adjacency.len() { return std::collections::HashMap::new(); } + let mut depths = std::collections::HashMap::from([(source, 0usize)]); + let mut queue = std::collections::VecDeque::from([source]); + while let Some(node) = queue.pop_front() { + let depth = depths[&node]; + if depth_limit.is_some_and(|limit| depth >= limit) { continue; } + for &next in &adjacency[node] { + if allowed.get(next).copied().unwrap_or(false) && !depths.contains_key(&next) { + depths.insert(next, depth + 1); + queue.push_back(next); + } + } + } + depths +} +``` + +- [ ] Classify umbrellas from a module's source-path stem having loaded descendants, tests from the specified path roots, tactics from `Mathlib.Tactic`, and external nodes from source/stub attributes. Add category tests with arbitrary high-degree non-umbrella modules to prevent classification by degree alone. +- [ ] Run `cargo +1.96.0 test -p hyper-viz --offline`. Expected: all core tests pass. Commit this task. + +## Task 3: Explorer state, exact selection, history, and session compatibility + +**Files:** create `crates/hyper-viz-bevy/src/explorer_state.rs`; modify `lib.rs`, `graph.rs`, `session.rs`, and `crates/hyper-viz/src/session.rs`. + +**Consumes:** `DependencyIndex`, `ScopeOptions`, categories, and current scene epoch. + +**Produces:** + +```rust +pub enum ViewMode { Auto, Spatial, Dependencies } +pub struct ModuleFilters { + pub umbrella: bool, pub tests: bool, pub tactics: bool, pub external: bool, +} +pub struct ExplorerSnapshot { + pub center: Option, pub target: Option, + pub options: ScopeOptions, pub filters: ModuleFilters, + pub expanded_ids: Vec, pub pan: [f32; 2], pub zoom: f32, +} +impl ExplorerState { + pub fn replace_scene(&mut self, scene: &HypergraphScene, epoch: u64); + pub fn select_module(&mut self, id: &str); + pub fn search(&mut self, query: &str) -> &[usize]; + pub fn back(&mut self); + pub fn forward(&mut self); + pub fn snapshot(&self) -> ExplorerSnapshot; + pub fn restore(&mut self, snapshot: ExplorerSnapshot); +} +// Embedding override: +pub fn with_view_mode(self, mode: ViewMode) -> Self; +// Runtime predicates registered with spatial systems: +pub fn spatial_mode(state: Option>) -> bool; +``` + +- [ ] Add tests for exact module selection despite overlapping node/group search names, back/forward restoration of filters and expansion, reordering IDs, disappearing centers, and cached unchanged queries. Add session round trips for old JSON, explorer state, opacity `0.001`, and invalid saved values. +- [ ] Run the targeted state/session tests before implementation. Expected: new API/fields are absent or selection/restore assertions fail. +- [ ] Build the index and category mask once per epoch. Cache a case-folded search corpus; rank exact ID first, then prefix/substring candidates, then fuzzy fallback. Return at most 20 result rows. Search remains global; selecting a row is the only action that changes the center. +- [ ] Add bounded history of 32 snapshots, stable IDs, and restore normalization. Clamp hops to `1..=6`, budget to `1..=1000`, zoom to `0.1..=4.0`; replace nonfinite pan with `[0,0]` and nonfinite zoom with `1`. Preserve a missing center's ID for the empty-state message. + +```rust +snapshot.options.budget = snapshot.options.budget.clamp(1, 1000); +snapshot.zoom = if snapshot.zoom.is_finite() { + snapshot.zoom.clamp(0.1, 4.0) +} else { 1.0 }; +if snapshot.pan.iter().any(|value| !value.is_finite()) { + snapshot.pan = [0.0, 0.0]; +} +``` + +- [ ] Add defaulted per-view explorer session data. Preserve `HYPER_VIZ_SESSION=off`, generic localization, host-backed search, camera pose, and stable-ID selection. Initialize auto mode after the scene is available and before spatial consumers; imported graphs choose Dependencies, generic graphs choose Spatial. Showcase explicitly chooses Spatial. +- [ ] Run `cargo +1.96.0 test --workspace --offline`. Expected: old and new state/session tests pass. Commit this task. + +## Task 4: Build the native module canvas and exploration UI + +**Files:** create `crates/hyper-viz-bevy/src/explorer_ui.rs` and `explorer_canvas.rs`; modify `ui.rs`, `camera.rs`, `focus.rs`, and `lib.rs`. + +**Consumes:** explorer state, core index, scopes, paths, categories, and shared visibility. + +**Produces:** + +```rust +pub struct CanvasNode { pub index: usize, pub position: [f32; 2] } +pub fn layered_positions(index: &DependencyIndex, scope: &DependencyScope, canonical: &DependencyScope) -> Vec; +pub fn explorer_panel(contexts: EguiContexts, state: ResMut, layout: ResMut, focus: ResMut); +``` + +- [ ] Add tests for one node appearing once when reached in both directions, deterministic layout after scene reordering, and retained rows when filters/budget remove other nodes. Test transfer to spatial focus retains only directed displayed edges plus the explicitly chosen group. +- [ ] Run the targeted canvas/focus tests. Expected: missing layout function or incorrect focus assertions. +- [ ] Lay out dependents at negative x and imports at positive x using hop depth. Center the selected module at `[0,0]`; choose dependent placement deterministically for dual-role nodes and label both roles. Order each canonical layer by stable ID. Derive row positions before filtering/truncation to preserve retained node positions. + +```rust +let column = if node == center { 0 } else if let Some(depth) = dependent_depth { + -(depth as i32) +} else { import_depth.unwrap_or(1) as i32 }; +let position = [column as f32 * 340.0, canonical_row as f32 * 44.0]; +``` + +- [ ] Render an egui central canvas with directed arrows, short suffix labels, full-name tooltips, category colors/legend, pan/zoom/fit, selected-node details, and an explicit list-view toggle. Use scoped clipped painting. Overlay arrows only for displayed directed relations. No match produces an explanatory empty state. +- [ ] Build search/selection, history, independent imports/dependents depth and transitive controls, category switches, budget disclosure, full direct neighbor lists, and one-hop expansion/reset. Lists show complete names and counts independent of graph truncation; clicking a neighbor recenters the view. +- [ ] Add the second module picker and ordered shortest-path list. Compare filtered and unfiltered queries to distinguish absent versus filter-blocked paths. Include zero-hop paths; keep the full textual path even when the graph is truncated. Highlight displayed path segments. +- [ ] Add explicit group inspection for the center's exported group with the authoritative member list. Transfer the bounded scope to spatial focus, preserving source edge IDs. Switching modes hides spatial entities/hulls and gates force steps, 3D picking, and spatial HUD/camera interactions; restore the previous spatial running state on return. +- [ ] Use visible-node counts for spatial label eligibility. Run `cargo +1.96.0 test --workspace --offline`. Expected: all suites pass. Commit this task. + +## Task 5: Correct controls and remove spatial rendering waste + +**Files:** `crates/hyper-viz-bevy/src/{ui,graph,render,interaction,hyperedge_hull,node_visual}.rs`; create `spatial_visibility.rs`; `crates/hyper-viz/src/layout.rs` if deterministic initialization needs a reusable helper. + +**Consumes:** mode predicate, hubless scope policy, explicit scoped edges, and retained metadata. + +**Produces:** shared visible-line enumeration used by drawing/picking/counts, geometry dirty tracking, a spatial preset, and measured `LayoutTiming { steps: usize, milliseconds: f64 }`. + +- [ ] Add regression tests proving budget-hidden lines are not pickable, focused imports precede unrelated lines, frozen hull geometry is unchanged, style-only edits do not rebuild topology, and dyadic edges never enter hull creation. Test that the simulation executes at least one step under an exhausted budget and reports actual steps. +- [ ] Run targeted tests before implementation. Expected: visibility or geometry assertions fail, or new helpers are missing. +- [ ] Centralize visible-line enumeration. Respect explicit edge scope, node scope, hidden hubs, and the same budget for rendering and picking. Return omitted-line counts for the HUD. Build static pick caches keyed by scene/position/visibility revisions. +- [ ] Skip arity below three before member-position allocation. Use iteration/revision changes for geometry dirtiness and material-only changes for style. Stage egui settings in local copies and update resources only when the values differ. + +```rust +if hyperedge.member_indices.len() < 3 { continue; } +let positions_changed = layout.layout.iterations != last_iterations; +let geometry_dirty = positions_changed || epoch.0 != last_epoch; +if geometry_dirty { + let Some(hull_mesh) = hull_from_points(&all_positions) else { continue; }; + if let Some(mesh) = meshes.get_mut(&mesh_handle) { + apply_hull_mesh(mesh, &hull_mesh); + } +} +if let Some(material) = materials.get_mut(&mat_handle) { + if material.base_color != fill { material.base_color = fill; } +} +``` + +- [ ] Apply all numeric ranges from Global Constraints. Expose gravity/centroid off switches, logarithmic positive controls, precise numeric entry, and line opacity. Render spring controls only when `layout.edges` is nonempty and centroid controls only when centroid groups are nonempty. Preserve valid saved values outside the slider track without silent clamp. +- [ ] Add the large directed-import spatial preset with deterministic subject seeds and low background line opacity. Keep generic defaults and tour settings explicit. Track actual CPU steps with `Instant`, stopping after a step when elapsed time reaches 8 ms or the requested maximum is met. Do not change dt to satisfy the budget. +- [ ] Run workspace tests and clippy. Expected: pass, including generic viewer behavior, frozen geometry, and small opacity round trips. Commit this task. + +## Task 6: Verify Mathlib, document, review, and open the PR + +**Files:** add `crates/hyper-viz/examples/inspect_dependencies.rs`; update `docs/mathlib-demo.md`, `docs/VIEWER.md`, both crate READMEs, and the root README. Record validation in the execution ledger; keep temporary screenshots/measurements under ignored `target/`. + +**Consumes:** finished module explorer, directed index, spatial controls, and all previous task checks. + +- [ ] Add a headless inspection example taking a graph file and exact module ID. Print direct import/dependent IDs, scope size, and warnings; return a nonzero exit for a missing module. Verify it on a small fixture with hand-derived neighbors first. +- [ ] Run the actual dataset acceptance command, using the existing graph read-only if it is available only in the primary checkout: + +```sh +cargo +1.96.0 run --release -p hyper-viz --example inspect_dependencies -- /Users/bregy/Documents/atomicstrata/hyper/target/mathlib/graph.json Mathlib.Topology.Basic +``` + +Expected: imports `Mathlib.Data.Set.Finite.Range`, `Mathlib.Data.Set.Lattice.Bounded`, and `Mathlib.Topology.Defs.Filter`; dependents `Mathlib`, `Mathlib.Topology.Closure`, `Mathlib.Topology.Defs.Induced`, and `MathlibTest.Tactic.Continuity`; eight vertices with all filters included. + +- [ ] Run formatting, workspace tests, workspace clippy, exporter tests, and release build: + +```sh +cargo +1.96.0 fmt --all -- --check +cargo +1.96.0 test --workspace --offline +cargo +1.96.0 clippy --workspace --all-targets --offline -- -D warnings +python3 -m unittest discover -s scripts -p 'test_*.py' +cargo +1.96.0 build --release --offline +git diff --check +``` + +Expected: successful exits. Read every output; identify pre-existing failures by name rather than claiming a clean suite. + +- [ ] Launch the native viewer with `HYPER_VIZ_SESSION=off target/release/hyper --projection star /Users/bregy/Documents/atomicstrata/hyper/target/mathlib/graph.json`. Inspect exact selection, neighbor lists, arrows, filters, expansion, path tracing, group inspection, mode transfer, and small coauthorship fallback. Record interactive median/p95 frame times at 200 visible nodes and disclose whether the 30 FPS target is met. Do not substitute tour capture or a headless timing for this measurement. +- [ ] Document the launch command, module workflow, arrow semantics, filters/truncation, optional spatial preset, embedding view override, scene API migration, and verification limitations. Update the ledger's acceptance checklist. Commit documentation and final verified changes. +- [ ] Run one independent whole-branch code review using the requesting-code-review skill. Supply the spec, plan, diff, checks, Review Focus above, and ledger rulings. Reproduce important findings with failing tests, fix them, and rerun affected/full checks. Verify final Git status and commits. +- [ ] Verify repository default branch and authentication through `gh`. Write a PR body to a temporary file with the concrete behavior, scene API changes, validation, and any material limitation. Push the verified branch and open a PR using the user's existing authorization: + +```sh +git push --set-upstream origin codex/module-dependency-explorer +gh pr create --title "Add directed module dependency exploration" --body-file /tmp/hyper-module-dependency-pr.md +``` + +Expected: the pushed branch and a PR URL. Attach every created PR using `mcp__codex_app__attach_artifact` and provide the URL plus launch command in the final response. If authentication or automatic approval review blocks publication, complete unaffected work and report the concrete blocker without claiming the PR exists. diff --git a/docs/superpowers/specs/2026-10-02-module-dependency-explorer-design.md b/docs/superpowers/specs/2026-10-02-module-dependency-explorer-design.md new file mode 100644 index 0000000..77c3c81 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-module-dependency-explorer-design.md @@ -0,0 +1,265 @@ +# Module dependency explorer + +Date: 2026-10-02 + +## Goal and approved direction + +Help the user understand a Mathlib module's imports and dependents. On selecting +one exact module, show its direct relationships with readable names and arrows, +then let the user expand the scope deliberately. Keep the original hypergraph +available for spatial exploration and optional group inspection. + +The user approved the investigation's recommendation on 2026-10-02. This document +makes that recommendation concrete for implementation. The first delivery covers +the module workflow, projection correctness, useful layout controls, and the +performance work needed by that workflow. A global subject aggregation map is +outside this delivery. + +## Evidence + +The existing Mathlib JSON has 9,384 vertices, 37,356 directed import relations, +and 7,550 dependency groups. Import direction lives in hyperedge attributes; +projection currently discards those attributes. + +`deps:Mathlib` contains 8,532 vertices. In the current input order, 4,473 of the +first 5,000 import lines originate at `Mathlib`. The line budget consequently +favors this umbrella module rather than a representative or useful neighborhood. + +The existing focus operation expands all incident hyperedges, including this +umbrella group. A headless probe found that focusing `Mathlib.Topology.Basic` +produces 8,534 vertices despite that module having only three direct imports and +four direct dependents. Its direct neighborhood needs eight vertices. + +Star projection has no link springs. A headless probe found identical positions +when changing its exposed attraction and ideal-length controls. Its effective +centroid attraction and gravity have no HUD controls. Interactive defaults are +16 and 20 times the corresponding tour settings. Every star hyperedge also uses +the fabricated hub index zero, which corrupts focus, visibility, and hub status. + +The screenshot reports approximately 5 FPS. A short headless layout probe took +approximately 12 ms per step; this is not an interactive renderer profile. +Performance acceptance therefore requires new viewer measurements. + +## User workflow + +### Open and find + +Graphs with validated directed import relations expose a "Module explorer" +mode alongside "Spatial graph". The standalone Mathlib CLI opens the module +explorer by default; generic hypergraphs retain the current spatial viewer. +Embedded hosts can choose the initial mode explicitly. + +The initial explorer screen explains that a module must be selected. Search +operates across the loaded graph, even when the current view is filtered. +Search shows a bounded result list with full module names. Exact ID matches +rank first; selecting a result or pressing Enter chooses one module. Typing +does not automatically select 32 nodes and hyperedges. The generic spatial +localization workflow and optional host-backed repository search remain available. + +Selecting a module pins it as the center, shows its file location, and opens +imports and dependents lists. Clicking a listed neighbor makes it the center. +Back and forward restore the center, expansion settings, and filters by stable +IDs. Missing centers after a reload show an explanatory empty state. + +### Relationships and expansion + +The default scope is direct imports and direct dependents, one hop each. Controls +can enable either direction independently and set their depths independently +from one to six. "Transitive" traverses the selected direction to closure, but +the display remains subject to a visible-node budget. + +Expansion follows directed import relations only. Membership in a dependency +group does not add co-members or the rest of an umbrella group. Every arrow +means "the source module imports the target module". + +Lists report complete direct counts and full names. The graph initially displays +at most 200 modules, always including the center. Additional expansion is ordered +by hop distance, then stable module ID. A disclosure reports how many reachable +modules are not displayed. The user can raise the budget to 1,000. Lists remain +scrollable independently of the graph's budget. + +An "Expand this module" action adds one hop of that module's enabled relationship +directions to the existing scope without replacing the center. It uses the same +filters and budget. A reset action returns to the center's initial neighborhood. + +### Filters + +Provide independent switches for umbrella modules, test modules, tactic modules, +and external modules. All start included so the initial counts faithfully match +the source data. Counts for hidden neighbors remain visible when filters change. +The selected center is always retained and visibly identified if it falls into +a filtered category. Traversal stops at filtered vertices; the UI describes this +as traversing the filtered graph. + +For Mathlib, classify modules using preserved metadata and module paths. An +umbrella module has a corresponding source directory containing other modules; +derive this from loaded module IDs and paths, without accessing the filesystem +or applying an arbitrary degree threshold. Tests include MathlibTest and +DownstreamTest. Tactics use Mathlib.Tactic. External vertices use their explicit +external/stub metadata. Generic graph consumers can ignore these domain filters. + +### Dependency paths and groups + +An optional second module picker asks "Does this module depend on that module?" +and finds a shortest directed import path from the center to the chosen target. +Show the ordered full names and highlight the path. Distinguish a zero-hop path, +no path in the loaded graph, and a path blocked by the active filters. A path +can exceed the normal visible budget; show its full textual list and disclose +graph truncation rather than silently omitting steps. + +The "Show dependency group" action displays only the chosen module's exported +dependency group. Its member list is authoritative. Membership visualization +does not imply that all imports depend on one another or form a community. +Do not automatically expand every group containing a selected module. + +## View and interaction + +Use a layered 2D canvas for the module explorer: dependents to the left, the +selected module at the center, and imports to the right. Nodes reached in both +directions appear once, with a deterministic placement and visible relation +types. Cycle-safe traversal is required even though this Mathlib dataset is +acyclic. Local expansion may add connections between layers; arrow direction +remains authoritative. + +Render the canvas in the existing native egui UI. Pan, zoom, fit, selection, +hover, tooltips, and short labels with full-name details operate on the bounded +scope. Keep a stable order and deterministic positions when switching filters +or changing the visible budget. A legend explains arrow direction and node +colors. Provide a list view for modules whose relationships are still dense. + +Spatial mode retains the Bevy viewer. An action transfers the current module +scope into spatial focus; another returns to the layered view. The layered +canvas does not run the global force simulation, create global hull meshes, +or perform 3D picking behind the UI. Returning to spatial mode restores its +previous running state. Spatial labels use the visible scope size when deciding +whether a neighborhood is small enough to label. + +Global hulls start disabled for large directed-import graphs. The chosen module's +dependency group can be inspected explicitly in spatial mode. Generic small +hypergraph hull defaults remain available. + +## Data and boundaries + +### Projection and scene identity + +Replace fabricated hub indices with optional hub identity. Bipartite scenes have +real hubs; star and clique scenes have no hubs. All consumers must distinguish +an actual hub from member vertices. Neighborhood and visibility checks use +explicit hyperedge membership or selected hyperedge indices when no hub exists. + +Preserve graph, vertex, and hyperedge attributes in the scene representation. +Derive import direction only from validated source/target attributes identifying +existing members of an arity-two import edge. Invalid direction produces a scene +warning and remains inspectable as a generic hyperedge; never guess orientation +from a label, vector order, or module name. + +The wire-format version stays `hypergraph.v1`. Scene deserialization defaults +new optional fields so saved scenes without attributes remain readable. The +Rust scene API changes are documented because adding fields and optional hubs +affects embedding hosts' struct literals. Audit every in-repository constructor +and consumer, including showcase, sessions, attention, and status animation. + +### Core exploration + +Add a Bevy-free directed dependency index responsible for outgoing and incoming +adjacency, bounded traversal, paths, direct counts, and deterministic scope +construction. Build it once per scene epoch. Deduplicate equivalent directed +pairs while retaining their source hyperedge identities for inspection. Traversal +must terminate on cycles, missing IDs, empty graphs, and self-loops. + +Keep module category interpretation separate from the generic directed index. +Filtering returns both displayed relationships and counts excluded by category +or budget. Group membership remains available separately from directed adjacency. + +### Viewer state + +A dedicated explorer module owns center/target IDs, history, cached search +results, expansion, filters, canvas transform, and rendering. The existing main +HUD remains responsible for spatial layout and visual settings. Cache search +results by query and scene epoch; an unchanged query does not rebuild and rank +the full corpus every frame. + +Session fields for the explorer are additive and defaulted under +`hyperviz.session.v1`. Store stable IDs and bounded history, not scene indices. +Validate restored numeric values and budget/depth limits. Session disabling via +`HYPER_VIZ_SESSION=off` continues to work. + +## Controls and spatial defaults + +Expose centroid attraction and gravity with an explicit zero/off state, numeric +entry, and logarithmic adjustment over positive values. Start with ranges +`0.000001–0.1` for centroid attraction and `0.00001–0.1` for gravity. Extend +repulsion to `1–100000`. Keep dt at `0.01–1` and damping at `0.5–0.99`. + +Show spring attraction and ideal length only when the current layout actually +uses link springs. Show centroid attraction only when centroid groups are used. +Allow hull opacity `0–0.6` with precision sufficient for `0.001`, and line +opacity `0–1`. Do not use a slider minimum to silently clamp a valid saved value. + +Provide a large-graph spatial preset with gravity `0.001`, centroid attraction +`0.0005`, deterministic subject initialization, hulls disabled, and subdued +background imports. This preset is a starting point for exploration, not a claim +of optimal layout quality. Keep the video tour's settings explicit and stable. + +Display the actual layout iterations executed and their measured duration. Use +an 8 ms per-frame simulation budget and a requested maximum step count; perform +at least one step when running, so expensive graphs still progress. The budget +is best-effort because a single CPU step cannot be interrupted. Freeze and resume +remain explicit user actions. Avoid implicit changes to dt. + +## Rendering and performance + +Skip arity-two hyperedges before hull construction or hull update allocation. +Only update geometry when positions or geometry settings actually change. +Style changes update materials without rebuilding hull topology. Passing an +unchanged setting to egui must not mark geometry dirty. + +Restrict picking to the geometry actually displayed under current scope, +visibility, and line budget. Cache static pick geometry and avoid global hull +triangle scans in the module explorer. Apply the same visibility decision to +labels, rendering, and picking. + +Use visible import relations to prioritize line rendering in focused spatial +views. When a global line budget omits relations, report the number omitted; +do not imply the displayed lines represent the entire dependency graph. + +Measure interactive frame time separately from headless layout steps and tour +capture. Target responsive local exploration with 200 nodes at 30 FPS on the +available machine, and record measured median/p95 rather than treating that +target as a guaranteed cross-platform performance claim. + +## Verification and acceptance + +1. A tiny graph containing an umbrella group focuses a selected module's direct + imports/dependents without expanding unrelated group members. +2. Selecting `Mathlib.Topology.Basic` in the actual dataset shows three imports + and four dependents, with eight total vertices before filters. Search selects + that single module. All seven arrows agree with the source/target attributes. +3. `Archive`, the first vertex, does not become a fake hub for all star hyperedges. + Star/clique focus, hull visibility, selection, and vertex status remain correct. +4. Two-hop imports and dependents are independently checked against a hand-built + directed fixture. Cycle, duplicate-edge, self-loop, missing-center, and empty + graph fixtures terminate and report sensible scopes. +5. Paths preserve direction. Reverse-only connectivity is not reported as a + dependency path. Filters and truncation remain explicit. +6. Unrendered lines cannot be picked. Small visible neighborhoods have labels + even when the loaded graph has thousands of vertices. +7. Changing the effective centroid force changes star-layout motion; inactive + spring controls are absent. Small hull opacity values survive UI and session + round trips. Frozen layouts do not rebuild unchanged hull geometry. +8. Existing coauthorship, bipartite, clique, hot-reload, embedding, session, and + tour tests pass. Direction or path metadata changes invalidate scene caches. +9. Run `cargo fmt --all -- --check`, `cargo test --workspace`, workspace clippy, + the Python exporter tests, and a release build. Verify the available installed + Rust toolchain satisfies locked dependencies without changing the declared + Rust floor as an unrelated fix. +10. Launch and visually inspect the native module view with the actual dataset; + measure its responsiveness and verify navigation, filters, expansion, path + tracing, and transfer to spatial mode. Report any inability to exercise the + window separately from automated verification. + +## Delivery + +Keep changes in the current managed worktree. Provide the launch command, a +brief description of the implemented behavior, and verification results. Do not +publish, merge, or alter the user's primary checkout as part of this work. From 7c4d7ed4b9eb0d92d99539601751ccaecd00c1f0 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 2 Oct 2026 23:53:07 -0500 Subject: [PATCH 02/19] fix: preserve scene metadata and remove fabricated projection hubs --- crates/hyper-viz-bevy/src/animation.rs | 2 +- crates/hyper-viz-bevy/src/focus.rs | 10 +- crates/hyper-viz-bevy/src/graph.rs | 4 +- crates/hyper-viz-bevy/src/hyperedge_hull.rs | 8 +- crates/hyper-viz-bevy/src/interaction.rs | 11 +- crates/hyper-viz-bevy/src/ui.rs | 6 +- crates/hyper-viz/README.md | 9 ++ crates/hyper-viz/src/lib.rs | 2 +- crates/hyper-viz/src/project.rs | 13 ++- crates/hyper-viz/src/scene.rs | 116 +++++++++++++++++++- crates/hyper-viz/src/semantics.rs | 3 + 11 files changed, 160 insertions(+), 24 deletions(-) diff --git a/crates/hyper-viz-bevy/src/animation.rs b/crates/hyper-viz-bevy/src/animation.rs index 44e370d..d0f8271 100644 --- a/crates/hyper-viz-bevy/src/animation.rs +++ b/crates/hyper-viz-bevy/src/animation.rs @@ -66,7 +66,7 @@ pub fn node_motion( let raw = scene .hyperedges .iter() - .find(|he| he.hub_index == index) + .find(|he| he.hub_index == Some(index)) .map(|he| he.status.as_str()) .unwrap_or(""); let key_id = node.hyperedge_id.as_deref().unwrap_or(&node.id); diff --git a/crates/hyper-viz-bevy/src/focus.rs b/crates/hyper-viz-bevy/src/focus.rs index 4bf2b7e..1f9564b 100644 --- a/crates/hyper-viz-bevy/src/focus.rs +++ b/crates/hyper-viz-bevy/src/focus.rs @@ -15,6 +15,7 @@ pub struct AttentionMode { #[derive(Resource, Debug, Clone, Default)] pub struct FocusScope { pub nodes: Option>, + pub hyperedges: Option>, } impl FocusScope { @@ -28,6 +29,11 @@ impl FocusScope { pub fn clear(&mut self) { self.nodes = None; + self.hyperedges = None; + } + + pub fn contains_hyperedge(&self, scene: &HypergraphScene, index: usize) -> bool { + hyper_viz::hyperedge_in_scope(scene, index, self.nodes.as_ref(), self.hyperedges.as_ref()) } } @@ -142,7 +148,7 @@ fn focus_seeds( let mut seeds = selection.to_vec(); for he_idx in selected_hyperedges { if let Some(he) = layout.scene.hyperedges.get(*he_idx) { - seeds.push(he.hub_index); + seeds.extend(he.hub_index); seeds.extend(he.member_indices.iter().copied()); } } @@ -161,6 +167,7 @@ pub fn isolate_selection( return; } scope.nodes = Some(neighborhood(&layout.scene, seeds)); + scope.hyperedges = None; } pub fn toggle_focus( @@ -181,6 +188,7 @@ pub fn toggle_focus( return; } scope.nodes = Some(neighborhood(&layout.scene, seeds)); + scope.hyperedges = None; } pub struct FocusPlugin; diff --git a/crates/hyper-viz-bevy/src/graph.rs b/crates/hyper-viz-bevy/src/graph.rs index f224e17..d957516 100644 --- a/crates/hyper-viz-bevy/src/graph.rs +++ b/crates/hyper-viz-bevy/src/graph.rs @@ -137,7 +137,9 @@ impl GraphLayout { fn hub_status_from_scene(scene: &HypergraphScene) -> Vec> { let mut hub_status = vec![None; scene.node_count()]; for he in &scene.hyperedges { - if let Some(slot) = hub_status.get_mut(he.hub_index) { + if let Some(hub) = he.hub_index + && let Some(slot) = hub_status.get_mut(hub) + { *slot = Some(he.status.clone()); } } diff --git a/crates/hyper-viz-bevy/src/hyperedge_hull.rs b/crates/hyper-viz-bevy/src/hyperedge_hull.rs index b52dded..b744a99 100644 --- a/crates/hyper-viz-bevy/src/hyperedge_hull.rs +++ b/crates/hyper-viz-bevy/src/hyperedge_hull.rs @@ -136,7 +136,7 @@ fn sync_hyperedge_hulls( } for (he_index, hyperedge) in layout.scene.hyperedges.iter().enumerate() { - if !focus.contains(hyperedge.hub_index) { + if !focus.contains_hyperedge(&layout.scene, he_index) { continue; } if !attention_keeps(parse_status(&hyperedge.status), attention.on) { @@ -159,7 +159,9 @@ fn sync_hyperedge_hulls( let emphasis = Emphasis::from_flags( hovered_he == Some(he_index), - selected_hubs.contains(&hyperedge.hub_index) + hyperedge + .hub_index + .is_some_and(|hub| selected_hubs.contains(&hub)) || sel_state.hyperedges.contains(&he_index), ); let style = @@ -306,7 +308,7 @@ fn draw_hull_wireframes( .scene .hyperedges .get(entity.hyperedge_index) - .is_some_and(|he| !focus.contains(he.hub_index)) + .is_some_and(|_| !focus.contains_hyperedge(&layout.scene, entity.hyperedge_index)) { continue; } diff --git a/crates/hyper-viz-bevy/src/interaction.rs b/crates/hyper-viz-bevy/src/interaction.rs index a5166c9..1afa26c 100644 --- a/crates/hyper-viz-bevy/src/interaction.rs +++ b/crates/hyper-viz-bevy/src/interaction.rs @@ -382,7 +382,9 @@ fn pointer_hover( .scene .hyperedges .get(entity.hyperedge_index) - .is_some_and(|he| focus.contains(he.hub_index)) + .is_some_and(|_| { + focus.contains_hyperedge(&layout.scene, entity.hyperedge_index) + }) { hull_hits.push((entity.hyperedge_index, cache.member_scene_indices.len(), t)); } @@ -398,9 +400,7 @@ fn pointer_hover( ) else { continue; }; - if !focus.contains(he.hub_index) - && !he.member_indices.iter().all(|idx| focus.contains(*idx)) - { + if !focus.contains_hyperedge(&layout.scene, he_index) { continue; } if let Some(t) = ray_segment_hit(ray, p1, p2, pick_radius) { @@ -434,7 +434,8 @@ fn pointer_hover( } } if !hide_hubs - && let Some(entity) = entity_by_index.get(he.hub_index).copied().flatten() + && let Some(hub) = he.hub_index + && let Some(entity) = entity_by_index.get(hub).copied().flatten() { keep.insert(entity); } diff --git a/crates/hyper-viz-bevy/src/ui.rs b/crates/hyper-viz-bevy/src/ui.rs index d136c2c..7fc3fed 100644 --- a/crates/hyper-viz-bevy/src/ui.rs +++ b/crates/hyper-viz-bevy/src/ui.rs @@ -509,7 +509,7 @@ fn flatten_hits(scene: &HypergraphScene, hits: &SceneHits, cap: usize) -> (Vec { hyperedges.push(index); if let Some(he) = scene.hyperedges.get(index) { - nodes.push(he.hub_index); + nodes.extend(he.hub_index); nodes.extend(he.member_indices.iter().copied()); } } @@ -736,8 +736,8 @@ fn draw_hyperedge_labels( egui::Id::new("hyperedge_labels"), )); - for he in &layout.scene.hyperedges { - if !focus.contains(he.hub_index) { + for (he_index, he) in layout.scene.hyperedges.iter().enumerate() { + if !focus.contains_hyperedge(&layout.scene, he_index) { continue; } if he.member_indices.is_empty() { diff --git a/crates/hyper-viz/README.md b/crates/hyper-viz/README.md index 0f255f5..88467dd 100644 --- a/crates/hyper-viz/README.md +++ b/crates/hyper-viz/README.md @@ -73,3 +73,12 @@ let hull = hull_style_emphasized("paper-a", "active", 0.28, Emphasis::Selected); ```bash cargo test -p hyper-viz ``` + +### Scene API migration + +Projected scenes now retain graph, vertex, and hyperedge `attrs`. Include +`attrs: Default::default()` in manual scene struct literals. `SceneHyperedge` +uses `hub_index: Option`: bipartite hubs are `Some(index)`, while star +and clique projections use `None`. Do not treat a member vertex as a hub. +New attributes and optional hubs default when reading older scene JSON; +`hypergraph.v1` input and `hyperviz.session.v1` sessions keep their versions. diff --git a/crates/hyper-viz/src/lib.rs b/crates/hyper-viz/src/lib.rs index b7db2bb..b5e1db3 100644 --- a/crates/hyper-viz/src/lib.rs +++ b/crates/hyper-viz/src/lib.rs @@ -60,7 +60,7 @@ pub use project::{Projection, project}; pub use query::{SceneHits, ScoredHit, scene_hits}; pub use scene::{ HypergraphScene, LinkKind, NodeRole, SceneHyperedge, SceneId, SceneIndex, SceneLink, SceneMeta, - SceneNode, neighborhood, scenes_equivalent, + SceneNode, hyperedge_in_scope, neighborhood, scenes_equivalent, }; pub use schema::{GRAPH_VERSION, GraphMeta, Hyperedge, Hypergraph, Vertex, sample_coauthorship}; pub use semantics::{ diff --git a/crates/hyper-viz/src/project.rs b/crates/hyper-viz/src/project.rs index cce1354..adee548 100644 --- a/crates/hyper-viz/src/project.rs +++ b/crates/hyper-viz/src/project.rs @@ -56,6 +56,7 @@ fn scene_meta(graph: &Hypergraph) -> SceneMeta { } else { graph.meta.title.clone() }, + attrs: graph.meta.attrs.clone(), } } @@ -86,6 +87,7 @@ fn scene_vertex_node(index: SceneIndex, vertex: &Vertex) -> SceneNode { }, hyperedge_id: None, status: vertex.status.clone(), + attrs: vertex.attrs.clone(), } } @@ -98,7 +100,7 @@ fn hub_label(edge: &Hyperedge) -> String { fn scene_hyperedge( edge: &Hyperedge, - hub_index: SceneIndex, + hub_index: Option, member_indices: Vec, ) -> SceneHyperedge { SceneHyperedge { @@ -108,6 +110,7 @@ fn scene_hyperedge( label: hub_label(edge), kind: edge.kind.clone(), status: edge.status.clone(), + attrs: edge.attrs.clone(), } } @@ -134,6 +137,7 @@ fn project_bipartite(graph: &Hypergraph) -> HypergraphScene { label: hub_label(edge), hyperedge_id: Some(edge.id.clone()), status: edge.status.clone(), + attrs: edge.attrs.clone(), }); let mut member_indices = Vec::new(); @@ -155,7 +159,7 @@ fn project_bipartite(graph: &Hypergraph) -> HypergraphScene { }); } - hyperedges.push(scene_hyperedge(edge, hub_index, member_indices)); + hyperedges.push(scene_hyperedge(edge, Some(hub_index), member_indices)); } HypergraphScene { @@ -198,8 +202,7 @@ fn project_clique(graph: &Hypergraph) -> HypergraphScene { } } - let hub_index = member_indices.first().copied().unwrap_or(0); - hyperedges.push(scene_hyperedge(edge, hub_index, member_indices)); + hyperedges.push(scene_hyperedge(edge, None, member_indices)); } HypergraphScene { @@ -229,7 +232,7 @@ fn project_star_centroid(graph: &Hypergraph) -> HypergraphScene { member_indices.push(member_index); } - hyperedges.push(scene_hyperedge(edge, 0, member_indices)); + hyperedges.push(scene_hyperedge(edge, None, member_indices)); } HypergraphScene { diff --git a/crates/hyper-viz/src/scene.rs b/crates/hyper-viz/src/scene.rs index 5d9c8e5..fc0a3ff 100644 --- a/crates/hyper-viz/src/scene.rs +++ b/crates/hyper-viz/src/scene.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use crate::schema::Hypergraph; @@ -30,6 +31,8 @@ pub struct SceneNode { /// Copied from the source vertex or hyperedge. Empty means default/active. #[serde(default)] pub status: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub attrs: Map, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -52,11 +55,14 @@ pub struct SceneLink { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SceneHyperedge { pub id: SceneId, - pub hub_index: SceneIndex, + #[serde(default)] + pub hub_index: Option, pub member_indices: Vec, pub label: String, pub kind: String, pub status: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub attrs: Map, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -64,6 +70,8 @@ pub struct SceneMeta { pub version: String, pub id: String, pub title: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub attrs: Map, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -131,6 +139,7 @@ pub fn scenes_equivalent(left: &HypergraphScene, right: &HypergraphScene) -> boo || other.label != node.label || other.status != node.status || other.hyperedge_id != node.hyperedge_id + || other.attrs != node.attrs { return false; } @@ -145,7 +154,12 @@ pub fn scenes_equivalent(left: &HypergraphScene, right: &HypergraphScene) -> boo let Some(other) = left_edges.get(he.id.as_str()) else { return false; }; - if other.kind != he.kind || other.label != he.label || other.status != he.status { + if other.kind != he.kind + || other.label != he.label + || other.status != he.status + || other.attrs != he.attrs + || other.hub_index.is_some() != he.hub_index.is_some() + { return false; } let left_members: HashSet<&str> = other @@ -174,22 +188,116 @@ pub fn neighborhood( let seeds: HashSet = seed_node_indices.into_iter().collect(); let mut out = seeds.clone(); for he in &scene.hyperedges { - let hits = seeds.contains(&he.hub_index) + let hits = he.hub_index.is_some_and(|hub| seeds.contains(&hub)) || he.member_indices.iter().any(|index| seeds.contains(index)); if hits { - out.insert(he.hub_index); + out.extend(he.hub_index); out.extend(he.member_indices.iter().copied()); } } out } +/// Shared visibility for real hubs and hubless member sets. An explicit edge +/// scope prevents unrelated hyperedges among focused vertices from leaking in. +pub fn hyperedge_in_scope( + scene: &HypergraphScene, + hyperedge_index: usize, + nodes: Option<&HashSet>, + hyperedges: Option<&HashSet>, +) -> bool { + let Some(he) = scene.hyperedges.get(hyperedge_index) else { + return false; + }; + if hyperedges.is_some_and(|edges| !edges.contains(&hyperedge_index)) { + return false; + } + nodes.is_none_or(|nodes| { + he.hub_index.is_some_and(|hub| nodes.contains(&hub)) + || (!he.member_indices.is_empty() + && he.member_indices.iter().all(|i| nodes.contains(i))) + }) +} + #[cfg(test)] mod tests { use super::*; use crate::project::{Projection, project}; use crate::schema::{Hyperedge, Hypergraph, Vertex}; + #[test] + fn star_first_vertex_does_not_focus_unrelated_groups() { + let graph = Hypergraph::new() + .vertex("a", "A", "module") + .vertex("b", "B", "module") + .vertex("c", "C", "module") + .vertex("d", "D", "module") + .hyperedge("ab", ["a", "b"], "AB") + .hyperedge("cd", ["c", "d"], "CD"); + for projection in [Projection::StarCentroid, Projection::CliqueExpansion] { + let scene = graph.project(projection); + assert_eq!(neighborhood(&scene, [0]), HashSet::from([0, 1])); + } + } + + #[test] + fn projection_retains_attributes_and_metadata_changes_invalidate_scene() { + let mut graph = Hypergraph::new() + .vertex("a", "A", "module") + .vertex("b", "B", "module") + .hyperedge("ab", ["a", "b"], "AB"); + graph.meta.attrs.insert("commit".into(), "revision".into()); + graph.vertices[0] + .attrs + .insert("path".into(), "a.lean".into()); + graph.hyperedges[0] + .attrs + .insert("source".into(), "a".into()); + graph.hyperedges[0] + .attrs + .insert("target".into(), "b".into()); + for projection in [ + Projection::Bipartite, + Projection::StarCentroid, + Projection::CliqueExpansion, + ] { + let first = graph.project(projection); + let value = serde_json::to_value(&first).unwrap(); + assert_eq!(value["meta"]["attrs"]["commit"], "revision"); + assert_eq!(value["nodes"][0]["attrs"]["path"], "a.lean"); + assert_eq!(value["hyperedges"][0]["attrs"]["source"], "a"); + let mut changed = graph.clone(); + changed.vertices[0] + .attrs + .insert("path".into(), "moved.lean".into()); + assert!(!scenes_equivalent(&first, &changed.project(projection))); + changed = graph.clone(); + changed.hyperedges[0] + .attrs + .insert("source".into(), "b".into()); + assert!(!scenes_equivalent(&first, &changed.project(projection))); + } + } + + #[test] + fn legacy_scene_json_without_attributes_still_loads() { + let graph = Hypergraph::new() + .vertex("a", "A", "module") + .vertex("b", "B", "module") + .hyperedge("ab", ["a", "b"], "AB"); + let mut value = serde_json::to_value(graph.project(Projection::Bipartite)).unwrap(); + value["meta"].as_object_mut().unwrap().remove("attrs"); + for node in value["nodes"].as_array_mut().unwrap() { + node.as_object_mut().unwrap().remove("attrs"); + } + for edge in value["hyperedges"].as_array_mut().unwrap() { + edge.as_object_mut().unwrap().remove("attrs"); + } + let scene: HypergraphScene = serde_json::from_value(value).unwrap(); + assert_eq!(scene.vertices_count(), 2); + assert_eq!(scene.hyperedges[0].member_indices, [0, 1]); + } + fn backlog_scene() -> HypergraphScene { let mut graph = Hypergraph::new(); graph.add_vertex(Vertex::new("repo", "mind").with_kind("repo")); diff --git a/crates/hyper-viz/src/semantics.rs b/crates/hyper-viz/src/semantics.rs index 034a47b..df823ea 100644 --- a/crates/hyper-viz/src/semantics.rs +++ b/crates/hyper-viz/src/semantics.rs @@ -400,6 +400,7 @@ mod tests { kind: "person".into(), label: "Alice".into(), hyperedge_id: None, + attrs: Default::default(), status: String::new(), }; let hub = SceneNode { @@ -409,6 +410,7 @@ mod tests { kind: "hyperedge".into(), label: "Paper A".into(), hyperedge_id: Some("e:1".into()), + attrs: Default::default(), status: String::new(), }; assert_ne!( @@ -444,6 +446,7 @@ mod tests { kind: "worktree".into(), label: "main".into(), hyperedge_id: None, + attrs: Default::default(), status: String::new(), }; let backlog = SceneNode { From 4f6dbdf0fe71131bde9190069c4f22524f6f3a34 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Fri, 2 Oct 2026 23:57:54 -0500 Subject: [PATCH 03/19] feat: index validated imports and query directed module scopes --- crates/hyper-viz/src/dependency.rs | 254 +++++++++++++++++++++++++++++ crates/hyper-viz/src/lib.rs | 4 + crates/hyper-viz/src/module.rs | 54 ++++++ crates/hyper-viz/src/project.rs | 6 +- 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 crates/hyper-viz/src/dependency.rs create mode 100644 crates/hyper-viz/src/module.rs diff --git a/crates/hyper-viz/src/dependency.rs b/crates/hyper-viz/src/dependency.rs new file mode 100644 index 0000000..e84cd2f --- /dev/null +++ b/crates/hyper-viz/src/dependency.rs @@ -0,0 +1,254 @@ +//! Directed import queries, independent of hyperedge membership. + +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use serde::{Deserialize, Serialize}; +use crate::{HypergraphScene, NodeRole}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TraversalDepth { Off, Hops(u8), Transitive } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ScopeOptions { + pub imports: TraversalDepth, + pub dependents: TraversalDepth, + pub budget: usize, +} +impl Default for ScopeOptions { + fn default() -> Self { Self { imports: TraversalDepth::Hops(1), dependents: TraversalDepth::Hops(1), budget: 200 } } +} +impl ScopeOptions { + pub fn normalize(&mut self) { + self.budget = self.budget.clamp(1, 1000); + for depth in [&mut self.imports, &mut self.dependents] { + if let TraversalDepth::Hops(hops) = depth { *hops = (*hops).clamp(1, 6); } + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DependencyScope { + pub center: Option, + pub nodes: Vec, + pub edges: Vec<(usize, usize)>, + /// Depths cover all reached nodes, including budget-omitted nodes. + pub import_depths: HashMap, + pub dependent_depths: HashMap, + pub total_reachable: usize, + pub omitted: usize, +} +#[derive(Debug, Clone)] +pub struct DirectedImport { + pub source: usize, + pub target: usize, + pub hyperedge_indices: Vec, +} +#[derive(Debug, Clone, Default)] +pub struct DependencyIndex { + ids: HashMap, + node_ids: Vec, + outgoing: Vec>, + incoming: Vec>, + relations: Vec, + warnings: Vec, +} + +/// Validate direction explicitly; set order and labels never imply direction. +fn endpoints(scene: &HypergraphScene, edge: usize, ids: &HashMap) -> Result, String> { + let he = &scene.hyperedges[edge]; + if he.kind != "import" { return Ok(None); } + let direction = (|| { + if he.member_indices.len() != 2 { return None; } + let source = *ids.get(he.attrs.get("source")?.as_str()?)?; + let target = *ids.get(he.attrs.get("target")?.as_str()?)?; + if !he.member_indices.contains(&source) || !he.member_indices.contains(&target) { return None; } + Some((source, target)) + })(); + direction.map(Some).ok_or_else(|| format!("Import {} has invalid source/target attributes or members; displayed as an unordered hyperedge", he.id)) +} + +pub(crate) fn import_warnings(scene: &HypergraphScene) -> Vec { + let ids = vertex_ids(scene); + (0..scene.hyperedges.len()).filter_map(|i| endpoints(scene, i, &ids).err()).collect() +} +fn vertex_ids(scene: &HypergraphScene) -> HashMap { + scene.nodes.iter().enumerate().filter(|(_, n)| n.role == NodeRole::Vertex) + .map(|(i,n)| (n.id.clone(),i)).collect() +} + +impl DependencyIndex { + pub fn new(scene: &HypergraphScene) -> Self { + let ids = vertex_ids(scene); + let mut pairs: BTreeMap<(usize, usize), Vec> = BTreeMap::new(); + let mut warnings = Vec::new(); + for i in 0..scene.hyperedges.len() { + match endpoints(scene, i, &ids) { + Ok(Some(pair)) => pairs.entry(pair).or_default().push(i), + Err(warning) => warnings.push(warning), + _ => (), + } + } + let node_ids: Vec<_> = scene.nodes.iter().map(|n|n.id.clone()).collect(); + let mut outgoing = vec![Vec::new(); node_ids.len()]; + let mut incoming = outgoing.clone(); + let mut relations: Vec<_> = pairs.into_iter().map(|((source,target),hyperedge_indices)| { + outgoing[source].push(target); incoming[target].push(source); + DirectedImport {source,target,hyperedge_indices} + }).collect(); + for neighbors in outgoing.iter_mut().chain(incoming.iter_mut()) { + neighbors.sort_by(|a,b|node_ids[*a].cmp(&node_ids[*b])); + } + relations.sort_by(|a,b| (&node_ids[a.source],&node_ids[a.target]).cmp(&(&node_ids[b.source],&node_ids[b.target]))); + Self {ids,node_ids,outgoing,incoming,relations,warnings} + } + pub fn node_index(&self, id: &str) -> Option { self.ids.get(id).copied() } + pub fn id(&self, node: usize) -> Option<&str> { self.node_ids.get(node).map(String::as_str) } + pub fn imports(&self, node: usize) -> &[usize] { self.outgoing.get(node).map(Vec::as_slice).unwrap_or(&[]) } + pub fn dependents(&self, node: usize) -> &[usize] { self.incoming.get(node).map(Vec::as_slice).unwrap_or(&[]) } + pub fn relations(&self) -> &[DirectedImport] { &self.relations } + pub fn warnings(&self) -> &[String] { &self.warnings } + pub fn is_empty(&self) -> bool { self.relations.is_empty() } + fn valid_node(&self, node: usize) -> bool { self.id(node).and_then(|id|self.node_index(id)) == Some(node) } + + pub fn scope(&self, center: usize, options: &ScopeOptions, allowed: &[bool]) -> DependencyScope { + if !self.valid_node(center) { return DependencyScope::default(); } + let imports = traverse(&self.outgoing, center, options.imports, allowed); + let dependents = traverse(&self.incoming, center, options.dependents, allowed); + self.finish_scope(center, imports, dependents, options.budget) + } + pub fn expand(&self, center: usize, previous: &DependencyScope, node: usize, options: &ScopeOptions, allowed: &[bool]) -> DependencyScope { + if !self.valid_node(center) || !self.valid_node(node) { return previous.clone(); } + if node != center && !allowed.get(node).copied().unwrap_or(false) { return previous.clone(); } + let offset = previous.import_depths.get(&node).into_iter().chain(previous.dependent_depths.get(&node)).copied().min().unwrap_or(0); + let mut imports = previous.import_depths.clone(); + let mut dependents = previous.dependent_depths.clone(); + for (adjacency, depth, into) in [(&self.outgoing, options.imports, &mut imports), (&self.incoming, options.dependents, &mut dependents)] { + if depth == TraversalDepth::Off { continue; } + for (next, hops) in traverse(adjacency, node, TraversalDepth::Hops(1), allowed) { + let value = offset + hops; + into.entry(next).and_modify(|d|*d=(*d).min(value)).or_insert(value); + } + } + self.finish_scope(center, imports, dependents, options.budget) + } + fn finish_scope(&self, center: usize, imports: HashMap, dependents: HashMap, budget: usize) -> DependencyScope { + let mut nodes: Vec<_> = imports.keys().chain(dependents.keys()).copied().collect::>().into_iter().collect(); + nodes.sort_by(|a,b| { + let distance = |node| imports.get(&node).into_iter().chain(dependents.get(&node)).copied().min().unwrap_or(usize::MAX); + distance(*a).cmp(&distance(*b)).then_with(||self.node_ids[*a].cmp(&self.node_ids[*b])) + }); + // Center is always first, even if the caller deliberately disabled it. + if let Some(i) = nodes.iter().position(|i|*i==center) { nodes.remove(i); } + nodes.insert(0,center); + let total_reachable = nodes.len(); + nodes.truncate(budget.clamp(1,1000)); + let visible: HashSet<_> = nodes.iter().copied().collect(); + let edges = self.relations.iter().filter(|e|visible.contains(&e.source) && visible.contains(&e.target)).map(|e|(e.source,e.target)).collect(); + DependencyScope {center:Some(center),omitted:total_reachable-nodes.len(),nodes,edges,import_depths:imports,dependent_depths:dependents,total_reachable} + } + pub fn shortest_path(&self, source: usize, target: usize, allowed: &[bool]) -> Option> { + if !self.valid_node(source) || !self.valid_node(target) || !allowed.get(source).copied().unwrap_or(false) || !allowed.get(target).copied().unwrap_or(false) { return None; } + let mut previous = vec![None;self.node_ids.len()]; + previous[source]=Some(source); + let mut queue=VecDeque::from([source]); + while let Some(node)=queue.pop_front() { + if node==target { + let mut path=vec![node]; let mut current=node; + while current!=source { current=previous[current]?; path.push(current); } + path.reverse(); return Some(path); + } + for &next in self.imports(node) { + if allowed.get(next).copied().unwrap_or(false) && previous[next].is_none() { + previous[next]=Some(node);queue.push_back(next); + } + } + } + None + } +} +fn traverse(adjacency: &[Vec], source: usize, limit: TraversalDepth, allowed: &[bool]) -> HashMap { + let depth_limit = match limit {TraversalDepth::Off=>Some(0),TraversalDepth::Hops(n)=>Some(n as usize),TraversalDepth::Transitive=>None}; + let mut depths=HashMap::from([(source,0)]); let mut queue=VecDeque::from([source]); + while let Some(node)=queue.pop_front() { + let depth=depths[&node]; if depth_limit.is_some_and(|limit|depth>=limit) {continue;} + for &next in &adjacency[node] { + if allowed.get(next).copied().unwrap_or(false) && !depths.contains_key(&next) { + depths.insert(next,depth+1);queue.push_back(next); + } + } + } + depths +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Hypergraph, Hyperedge, Projection}; + fn fixture() -> crate::HypergraphScene { + let mut g = Hypergraph::new(); + for id in ["a", "b", "c", "d", "x", "z"] { g = g.vertex(id, id, "module"); } + for (source,target) in [("a","b"),("b","c"),("d","a"),("x","d")] { + let mut e = Hyperedge::new(format!("{source}:{target}"), [source,target]).with_kind("import"); + e.attrs.insert("source".into(), source.into()); + e.attrs.insert("target".into(), target.into()); + g.add_hyperedge(e); + } + g = g.hyperedge("umbrella", ["a","b","c","d","x","z"], "all"); + g.project(Projection::StarCentroid) + } + fn ids(scene: &crate::HypergraphScene, nodes: &[usize]) -> Vec { + let mut ids: Vec<_> = nodes.iter().map(|i|scene.nodes[*i].id.clone()).collect(); ids.sort(); ids + } + #[test] + fn directed_scope_ignores_umbrella_and_expands_directions_independently() { + let scene=fixture(); let index=DependencyIndex::new(&scene); + let mut options=ScopeOptions::default(); let allowed=vec![true;scene.nodes.len()]; + assert_eq!(ids(&scene,&index.scope(0,&options,&allowed).nodes), ["a","b","d"]); + options.imports=TraversalDepth::Hops(2); + assert_eq!(ids(&scene,&index.scope(0,&options,&allowed).nodes), ["a","b","c","d"]); + options.imports=TraversalDepth::Hops(1); options.dependents=TraversalDepth::Hops(2); + assert_eq!(ids(&scene,&index.scope(0,&options,&allowed).nodes), ["a","b","d","x"]); + } + #[test] + fn paths_obey_direction_filters_and_zero_hops() { + let scene=fixture(); let index=DependencyIndex::new(&scene); let mut allowed=vec![true;6]; + assert_eq!(index.shortest_path(0,2,&allowed), Some(vec![0,1,2])); + assert_eq!(index.shortest_path(2,0,&allowed), None); + assert_eq!(index.shortest_path(0,0,&allowed), Some(vec![0])); + allowed[1]=false; assert_eq!(index.shortest_path(0,2,&allowed),None); + assert_eq!(index.scope(0,&ScopeOptions {imports:TraversalDepth::Transitive, dependents:TraversalDepth::Off, budget:200},&allowed).nodes,[0]); + } + #[test] + fn cycles_duplicates_and_self_loops_terminate_and_keep_edge_identity() { + let mut scene=fixture(); + scene.hyperedges.push(scene.hyperedges[0].clone()); + let mut reverse=scene.hyperedges[0].clone(); reverse.id="reverse".into(); + reverse.attrs.insert("source".into(),"b".into()); reverse.attrs.insert("target".into(),"a".into()); + scene.hyperedges.push(reverse); + let mut self_edge=scene.hyperedges[0].clone(); self_edge.id="self".into(); self_edge.member_indices=vec![0,0]; + self_edge.attrs.insert("target".into(),"a".into()); scene.hyperedges.push(self_edge); + let index=DependencyIndex::new(&scene); + let options=ScopeOptions {imports:TraversalDepth::Transitive, dependents:TraversalDepth::Off,budget:200}; + assert_eq!(ids(&scene,&index.scope(0,&options,&[true;6]).nodes),["a","b","c"]); + assert_eq!(index.relations().iter().find(|e|e.source==0 && e.target==1).unwrap().hyperedge_indices.len(),2); + } + #[test] + fn budgets_are_deterministic_and_expansion_keeps_center_and_counts() { + let scene=fixture();let index=DependencyIndex::new(&scene); + let options=ScopeOptions {budget:2,..Default::default()}; + let scope=index.scope(0,&options,&[true;6]); + assert_eq!(scope.nodes,[0,1]); assert_eq!(scope.total_reachable,3);assert_eq!(scope.omitted,1); + let expanded=index.expand(0,&scope,1,&ScopeOptions::default(),&[true;6]); + assert_eq!(ids(&scene,&expanded.nodes),["a","b","c","d"]); + assert_eq!(index.scope(100,&options,&[true;6]).nodes.len(),0); + assert_eq!(DependencyIndex::new(&Hypergraph::new().project(Projection::StarCentroid)).scope(0,&options,&[]).nodes.len(),0); + } + #[test] + fn malformed_direction_is_not_guessed_from_label_or_member_order() { + let mut scene=fixture();scene.hyperedges[0].attrs.insert("target".into(),"z".into()); + scene.hyperedges[1].attrs.remove("source"); + let index=DependencyIndex::new(&scene);assert!(index.imports(0).is_empty()); + assert_eq!(index.warnings().len(),2); + assert_eq!(scene.hyperedges.len(),5); + } +} diff --git a/crates/hyper-viz/src/lib.rs b/crates/hyper-viz/src/lib.rs index b5e1db3..491d3e6 100644 --- a/crates/hyper-viz/src/lib.rs +++ b/crates/hyper-viz/src/lib.rs @@ -27,6 +27,8 @@ //! assert_eq!(layout.positions.len(), scene.node_count()); //! ``` +pub mod dependency; +pub mod module; pub mod error; pub mod hif; pub mod hull; @@ -45,6 +47,8 @@ pub use hif::{ HifDocument, HifEdge, HifError, HifErrorKind, HifId, HifIncidence, HifNode, load_hif, parse_hif, save_hif, serialize_hif, }; +pub use dependency::{DependencyIndex, DependencyScope, DirectedImport, ScopeOptions, TraversalDepth}; +pub use module::{ModuleCategory, ModuleFilters, module_categories}; pub use hull::{HullMesh, MAX_HULL_VERTICES, hull_from_points, select_hull_vertices}; pub use io::{ InputFormat, from_json_str, from_json_str_with_format, load_json, load_json_with_format, diff --git a/crates/hyper-viz/src/module.rs b/crates/hyper-viz/src/module.rs new file mode 100644 index 0000000..22ed1aa --- /dev/null +++ b/crates/hyper-viz/src/module.rs @@ -0,0 +1,54 @@ +//! Optional Mathlib module categories; directed adjacency stays domain-neutral. +use crate::{HypergraphScene, NodeRole}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ModuleCategory { pub umbrella: bool, pub test: bool, pub tactic: bool, pub external: bool } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ModuleFilters { pub umbrella: bool, pub tests: bool, pub tactics: bool, pub external: bool } +impl Default for ModuleFilters { + fn default() -> Self {Self {umbrella:true,tests:true,tactics:true,external:true}} +} +impl ModuleFilters { + pub fn allows(&self, category: ModuleCategory) -> bool { + (self.umbrella || !category.umbrella) && (self.tests || !category.test) + && (self.tactics || !category.tactic) && (self.external || !category.external) + } +} +pub fn module_categories(scene: &HypergraphScene) -> Vec { + let mut directories=HashSet::new(); + for node in &scene.nodes { + if node.role != NodeRole::Vertex {continue;} + if let Some(path)=node.attrs.get("path").and_then(|p|p.as_str()) { + let mut prefix=path; + while let Some((parent,_))=prefix.rsplit_once('/') { directories.insert(parent.to_owned());prefix=parent; } + } + } + scene.nodes.iter().map(|node| { + let path=node.attrs.get("path").and_then(|p|p.as_str()).unwrap_or(""); + ModuleCategory { + umbrella: path.strip_suffix(".lean").is_some_and(|stem|directories.contains(stem)), + test: node.id=="MathlibTest" || node.id.starts_with("MathlibTest.") || node.id=="DownstreamTest" || node.id.starts_with("DownstreamTest."), + tactic: node.id=="Mathlib.Tactic" || node.id.starts_with("Mathlib.Tactic."), + external: node.attrs.get("source").and_then(|s|s.as_str())==Some("external") || node.attrs.get("stub").and_then(|s|s.as_bool())==Some(true), + } + }).collect() +} +#[cfg(test)] +mod tests { + use super::*; + use crate::{Hypergraph,Projection}; + #[test] + fn umbrellas_use_source_directories_not_degree() { + let mut g=Hypergraph::new().vertex("Mathlib","Mathlib","module") + .vertex("Mathlib.Algebra.Basic","Basic","module") + .vertex("busy","busy","module").vertex("Aesop","Aesop","external"); + for (i,path) in [(0,"Mathlib.lean"),(1,"Mathlib/Algebra/Basic.lean"),(2,"busy.lean")] {g.vertices[i].attrs.insert("path".into(),path.into());} + g.vertices[3].attrs.insert("stub".into(),true.into()); + let c=module_categories(&g.project(Projection::StarCentroid)); + assert!(c[0].umbrella);assert!(!c[2].umbrella);assert!(c[3].external); + assert!(!ModuleFilters {external:false,..Default::default()}.allows(c[3])); + } +} diff --git a/crates/hyper-viz/src/project.rs b/crates/hyper-viz/src/project.rs index adee548..9c18373 100644 --- a/crates/hyper-viz/src/project.rs +++ b/crates/hyper-viz/src/project.rs @@ -29,13 +29,15 @@ impl Projection { } pub fn project(graph: &Hypergraph, projection: Projection) -> HypergraphScene { - project_inner(graph, projection) + let mut scene = project_inner(graph, projection); + scene.warnings.extend(crate::dependency::import_warnings(&scene)); + scene } impl Hypergraph { /// Project this hypergraph into a render-agnostic [`HypergraphScene`]. pub fn project(&self, projection: Projection) -> HypergraphScene { - project_inner(self, projection) + project(self, projection) } } From 7a14346b2b815f36cdc9fc5d3ec78cf042cb2850 Mon Sep 17 00:00:00 2001 From: Bregy Malpartida Date: Sat, 3 Oct 2026 00:26:14 -0500 Subject: [PATCH 04/19] feat: add native module exploration and scoped spatial controls --- crates/hyper-viz-bevy/src/animation.rs | 4 +- crates/hyper-viz-bevy/src/camera.rs | 10 +- crates/hyper-viz-bevy/src/explorer_canvas.rs | 289 ++++++++++ crates/hyper-viz-bevy/src/explorer_state.rs | 413 ++++++++++++++ crates/hyper-viz-bevy/src/explorer_ui.rs | 439 +++++++++++++++ crates/hyper-viz-bevy/src/focus.rs | 2 +- crates/hyper-viz-bevy/src/graph.rs | 134 ++++- crates/hyper-viz-bevy/src/hyperedge_hull.rs | 131 ++++- crates/hyper-viz-bevy/src/interaction.rs | 54 +- crates/hyper-viz-bevy/src/lib.rs | 73 ++- crates/hyper-viz-bevy/src/render.rs | 136 ++--- crates/hyper-viz-bevy/src/session.rs | 78 ++- crates/hyper-viz-bevy/src/showcase.rs | 6 +- .../hyper-viz-bevy/src/spatial_visibility.rs | 132 +++++ crates/hyper-viz-bevy/src/ui.rs | 133 ++++- crates/hyper-viz/src/dependency.rs | 512 ++++++++++++++---- crates/hyper-viz/src/lib.rs | 8 +- crates/hyper-viz/src/module.rs | 120 +++- crates/hyper-viz/src/project.rs | 4 +- crates/hyper-viz/src/session.rs | 86 +++ src/main.rs | 46 +- 21 files changed, 2475 insertions(+), 335 deletions(-) create mode 100644 crates/hyper-viz-bevy/src/explorer_canvas.rs create mode 100644 crates/hyper-viz-bevy/src/explorer_state.rs create mode 100644 crates/hyper-viz-bevy/src/explorer_ui.rs create mode 100644 crates/hyper-viz-bevy/src/spatial_visibility.rs diff --git a/crates/hyper-viz-bevy/src/animation.rs b/crates/hyper-viz-bevy/src/animation.rs index d0f8271..c079c6d 100644 --- a/crates/hyper-viz-bevy/src/animation.rs +++ b/crates/hyper-viz-bevy/src/animation.rs @@ -20,7 +20,9 @@ impl Plugin for StatusAnimationPlugin { app.init_resource::().add_systems( Update, ( - detect_status_changes.run_if(resource_exists::), + detect_status_changes + .run_if(crate::explorer_state::spatial_mode) + .run_if(resource_exists::), tick_status_bursts, ) .chain(), diff --git a/crates/hyper-viz-bevy/src/camera.rs b/crates/hyper-viz-bevy/src/camera.rs index 46eee28..051b23e 100644 --- a/crates/hyper-viz-bevy/src/camera.rs +++ b/crates/hyper-viz-bevy/src/camera.rs @@ -25,9 +25,13 @@ impl Plugin for CameraPlugin { .add_systems( Update, ( - release_text_focus_for_orbit, - auto_fit_camera.run_if(resource_exists::), - apply_frame_request.run_if(resource_exists::), + release_text_focus_for_orbit.run_if(crate::explorer_state::spatial_mode), + auto_fit_camera + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), + apply_frame_request + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), ), ); } diff --git a/crates/hyper-viz-bevy/src/explorer_canvas.rs b/crates/hyper-viz-bevy/src/explorer_canvas.rs new file mode 100644 index 0000000..08d8b27 --- /dev/null +++ b/crates/hyper-viz-bevy/src/explorer_canvas.rs @@ -0,0 +1,289 @@ +use crate::explorer_state::ExplorerState; +use bevy_egui::egui; +use hyper_viz::{DependencyIndex, DependencyScope, HypergraphScene}; +use std::collections::{BTreeMap, HashMap}; + +pub struct CanvasNode { + pub index: usize, + pub position: [f32; 2], +} +pub fn layered_positions( + index: &DependencyIndex, + scope: &DependencyScope, + canonical: &DependencyScope, +) -> Vec { + let Some(center) = scope.center else { + return Vec::new(); + }; + let mut columns: BTreeMap> = BTreeMap::new(); + for &node in canonical + .import_depths + .keys() + .chain(canonical.dependent_depths.keys()) + { + let column = if node == center { + 0 + } else if let Some(depth) = canonical.dependent_depths.get(&node) { + -(*depth as i32) + } else { + canonical.import_depths.get(&node).copied().unwrap_or(1) as i32 + }; + columns.entry(column).or_default().push(node); + } + let mut positions = HashMap::new(); + for (column, mut nodes) in columns { + nodes.sort_by(|a, b| index.id(*a).cmp(&index.id(*b))); + nodes.dedup(); + for (row, node) in nodes.iter().enumerate() { + positions.insert(*node, [column as f32 * 340., row as f32 * 44.]); + } + } + scope + .nodes + .iter() + .map(|i| CanvasNode { + index: *i, + position: positions.get(i).copied().unwrap_or([0., 0.]), + }) + .collect() +} + +pub fn canvas(ui: &mut egui::Ui, state: &mut ExplorerState, scene: &HypergraphScene) { + let nodes = layered_positions(&state.index, &state.scope, &state.canonical); + let (response, painter) = ui.allocate_painter(ui.available_size(), egui::Sense::drag()); + let rect = response.rect; + if state.fit_requested && !nodes.is_empty() { + let min = nodes + .iter() + .fold(egui::vec2(f32::INFINITY, f32::INFINITY), |v, n| { + egui::vec2(v.x.min(n.position[0]), v.y.min(n.position[1])) + }); + let max = nodes + .iter() + .fold(egui::vec2(f32::NEG_INFINITY, f32::NEG_INFINITY), |v, n| { + egui::vec2(v.x.max(n.position[0]), v.y.max(n.position[1])) + }); + let size = max - min; + state.current.zoom = ((rect.width() - 300.) / size.x.max(1.)) + .min((rect.height() - 120.) / size.y.max(1.)) + .clamp(0.1, 1.); + let midpoint = (min + max) * 0.5; + state.current.pan = [ + -midpoint.x * state.current.zoom, + -midpoint.y * state.current.zoom, + ]; + state.fit_requested = false; + } + + if response.dragged() { + let d = response.drag_delta(); + state.current.pan[0] += d.x; + state.current.pan[1] += d.y; + } + if response.hovered() { + let scroll = ui.input(|i| i.smooth_scroll_delta.y); + if scroll != 0. { + state.current.zoom = (state.current.zoom * (scroll * 0.002).exp()).clamp(0.1, 4.); + } + } + let zoom = state.current.zoom; + let origin = rect.center() + egui::vec2(state.current.pan[0], state.current.pan[1]); + let coords: HashMap<_, _> = nodes + .iter() + .map(|n| { + ( + n.index, + origin + egui::vec2(n.position[0], n.position[1]) * zoom, + ) + }) + .collect(); + for &(source, target) in &state.scope.edges { + let (Some(&a), Some(&b)) = (coords.get(&source), coords.get(&target)) else { + continue; + }; + if a == b { + continue; + } + let delta = b - a; + let unit = delta.normalized(); + let start = a + unit * 18. * zoom; + let end = b - unit * 18. * zoom; + let highlighted = state + .path + .as_ref() + .is_some_and(|p| p.windows(2).any(|pair| pair == [source, target])); + let color = if highlighted { + egui::Color32::YELLOW + } else { + egui::Color32::from_rgb(100, 135, 175) + }; + let stroke = egui::Stroke::new(if highlighted { 2.5 } else { 1. }, color); + painter.line_segment([start, end], stroke); + let head = 8. * zoom.clamp(0.6, 1.2); + let perpendicular = egui::vec2(-unit.y, unit.x); + painter.line_segment( + [end, end - unit * head + perpendicular * head * 0.45], + stroke, + ); + painter.line_segment( + [end, end - unit * head - perpendicular * head * 0.45], + stroke, + ); + } + let mut select = None; + for node in nodes { + let p = coords[&node.index]; + let center = Some(node.index) == state.scope.center; + let category = state.categories[node.index]; + let color = if center { + egui::Color32::from_rgb(255, 193, 80) + } else if category.external { + egui::Color32::from_rgb(175, 145, 225) + } else if category.test { + egui::Color32::from_rgb(240, 145, 155) + } else if category.tactic { + egui::Color32::from_rgb(100, 210, 150) + } else if category.umbrella { + egui::Color32::from_rgb(225, 160, 85) + } else { + egui::Color32::from_rgb(115, 175, 235) + }; + let hit = egui::Rect::from_center_size( + p, + if zoom >= 0.55 { + egui::vec2(240., 38.) * zoom + } else { + egui::vec2(8., 8.) + }, + ); + if !rect.intersects(hit) { + continue; + } + painter.circle_filled(p, if center { 10. } else { (6. * zoom).max(2.) }, color); + let id = &scene.nodes[node.index].id; + let short = id + .rsplit('.') + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("."); + if zoom >= 0.55 || center { + let label_pos = p + egui::vec2(12., 0.); + let galley = painter.layout_no_wrap( + short, + egui::FontId::proportional(if center { + 13. + } else { + 13. * zoom.clamp(0.7, 1.5) + }), + color, + ); + let label_pos = label_pos - egui::vec2(0., galley.size().y / 2.); + painter.rect_filled( + egui::Rect::from_min_size(label_pos, galley.size()).expand(2.), + 2., + ui.visuals().panel_fill, + ); + painter.galley(label_pos, galley, color); + } + let both = node.index != state.scope.center.unwrap_or(usize::MAX) + && state.scope.import_depths.contains_key(&node.index) + && state.scope.dependent_depths.contains_key(&node.index); + let tooltip = format!( + "{}{}\n{}", + id, + if both { " (import and dependent)" } else { "" }, + scene.nodes[node.index] + .attrs + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("") + ); + if ui + .interact( + hit.intersect(rect), + ui.id().with(node.index), + egui::Sense::click(), + ) + .on_hover_text(tooltip) + .clicked() + { + select = Some(id.clone()); + } + } + if let Some(id) = select { + state.select_module(&id); + } + painter.text( + rect.left_top() + egui::vec2(16., 16.), + egui::Align2::LEFT_TOP, + "Dependents <- Center -> Imports\nArrows: importer -> imported · Drag to pan · Scroll to zoom · Zoom in for labels; hover for full names", + egui::FontId::proportional(13.), + egui::Color32::GRAY, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn dual_role_nodes_once_and_retained_rows_stable() { + let scope = DependencyScope { + center: Some(0), + nodes: vec![0, 1, 2], + import_depths: HashMap::from([(0, 0), (1, 1), (2, 1)]), + dependent_depths: HashMap::from([(0, 0), (1, 1)]), + ..Default::default() + }; + let index = DependencyIndex::default(); + let all = layered_positions(&index, &scope, &scope); + assert_eq!(all.len(), 3); + assert!(all[1].position[0] < 0.); + let mut small = scope.clone(); + small.nodes = vec![0, 2]; + let visible = layered_positions(&index, &small, &scope); + assert_eq!(visible[1].position, all[2].position); + assert_eq!(all[0].position, [0., 0.]); + } + #[test] + fn rows_survive_filtering_and_scene_reordering() { + use hyper_viz::{Hyperedge, Hypergraph, Projection, ScopeOptions}; + let mut graph = Hypergraph::new() + .vertex("a", "a", "module") + .vertex("b", "b", "module") + .vertex("c", "c", "module"); + for target in ["b", "c"] { + let mut edge = Hyperedge::new(target, ["a", target]).with_kind("import"); + edge.attrs.insert("source".into(), "a".into()); + edge.attrs.insert("target".into(), target.into()); + graph.add_hyperedge(edge); + } + let positions = |scene: &HypergraphScene, filter: bool| { + let index = DependencyIndex::new(scene); + let center = index.node_index("a").unwrap(); + let canonical = index.scope( + center, + &ScopeOptions::default(), + &vec![true; scene.node_count()], + ); + let mut allowed = vec![true; scene.node_count()]; + if filter { + allowed[index.node_index("b").unwrap()] = false; + } + let scope = index.scope(center, &ScopeOptions::default(), &allowed); + layered_positions(&index, &scope, &canonical) + .into_iter() + .map(|n| (scene.nodes[n.index].id.clone(), n.position)) + .collect::>() + }; + let scene = graph.project(Projection::StarCentroid); + let all = positions(&scene, false); + assert_eq!(all["c"], [340., 44.]); + assert_eq!(positions(&scene, true)["c"], all["c"]); + graph.vertices.reverse(); + let reversed = graph.project(Projection::StarCentroid); + assert_eq!(positions(&reversed, false), all); + } +} diff --git a/crates/hyper-viz-bevy/src/explorer_state.rs b/crates/hyper-viz-bevy/src/explorer_state.rs new file mode 100644 index 0000000..908565f --- /dev/null +++ b/crates/hyper-viz-bevy/src/explorer_state.rs @@ -0,0 +1,413 @@ +use bevy::prelude::*; +use hyper_viz::session::{ExplorerSnapshot, ViewMode}; +use hyper_viz::{ + DependencyIndex, DependencyScope, HypergraphScene, ModuleCategory, module_categories, +}; + +#[derive(Resource, Default)] +pub struct ExplorerState { + pub mode: ViewMode, + pub requested: ViewMode, + pub user_mode: bool, + pub current: ExplorerSnapshot, + pub index: DependencyIndex, + pub categories: Vec, + pub scope: DependencyScope, + pub canonical: DependencyScope, + pub allowed: Vec, + pub path: Option>, + pub path_blocked: bool, + pub epoch: Option, + pub history: Vec, + pub cursor: usize, + corpus: Vec<(usize, String)>, + search_cache: std::collections::HashMap>, + pub group: Option, + pub fit_requested: bool, +} +impl ExplorerState { + pub fn choose_mode(&mut self, mode: ViewMode) { + self.mode = mode; + self.user_mode = true; + } + pub fn replace_scene(&mut self, scene: &HypergraphScene, epoch: u64) { + self.index = DependencyIndex::new(scene); + self.categories = module_categories(scene); + self.corpus = scene + .nodes + .iter() + .enumerate() + .filter(|(_, n)| n.role == hyper_viz::NodeRole::Vertex) + .map(|(i, n)| (i, n.id.to_lowercase())) + .collect(); + self.search_cache.clear(); + self.group = None; + self.epoch = Some(epoch); + if !self.user_mode { + if self.requested == ViewMode::Auto { + self.mode = if self.index.is_empty() { + ViewMode::Spatial + } else { + ViewMode::Dependencies + }; + } else { + self.mode = self.requested; + } + } + self.rebuild(); + } + pub fn select_module(&mut self, id: &str) { + if self.index.node_index(id).is_none() { + return; + } + if let Some(snapshot) = self.history.get_mut(self.cursor) { + *snapshot = self.current.clone(); + } + self.fit_requested = true; + self.current.center = Some(id.to_owned()); + self.current.expanded_ids.clear(); + self.current.pan = [0., 0.]; + self.current.zoom = 1.; + self.group = None; + self.rebuild(); + self.record(); + } + pub fn search(&mut self, query: &str) -> &[usize] { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return &[]; + } + if self.search_cache.contains_key(&query) { + return &self.search_cache[&query]; + } + + let mut candidates: Vec<_> = self + .corpus + .iter() + .filter_map(|(i, id)| { + let rank = if id == &query { + 0 + } else if id.starts_with(&query) { + 1 + } else if id.contains(&query) { + 2 + } else { + let mut letters = query.chars(); + let mut next = letters.next(); + for c in id.chars() { + if next == Some(c) { + next = letters.next(); + } + } + if next.is_some() { + return None; + } + 3 + }; + Some((rank, id, *i)) + }) + .collect(); + candidates.sort(); + let results = candidates.into_iter().take(20).map(|(_, _, i)| i).collect(); + if self.search_cache.len() >= 4 { + self.search_cache.clear(); + } + self.search_cache.insert(query.clone(), results); + &self.search_cache[&query] + } + pub fn snapshot(&self) -> ExplorerSnapshot { + self.current.clone() + } + pub fn restore(&mut self, mut snapshot: ExplorerSnapshot) { + snapshot.normalize(); + self.fit_requested = false; + self.current = snapshot; + self.group = None; + self.rebuild(); + } + pub fn record(&mut self) { + if self.history.get(self.cursor) == Some(&self.current) { + return; + } + if !self.history.is_empty() { + self.history.truncate(self.cursor + 1); + } + self.history.push(self.current.clone()); + if self.history.len() > 32 { + self.history.remove(0); + } + self.cursor = self.history.len() - 1; + } + pub fn back(&mut self) { + if let Some(snapshot) = self.history.get_mut(self.cursor) { + *snapshot = self.current.clone(); + } + if self.cursor > 0 { + self.cursor -= 1; + self.restore(self.history[self.cursor].clone()); + } + } + pub fn forward(&mut self) { + if let Some(snapshot) = self.history.get_mut(self.cursor) { + *snapshot = self.current.clone(); + } + if self.cursor + 1 < self.history.len() { + self.cursor += 1; + self.restore(self.history[self.cursor].clone()); + } + } + pub fn rebuild(&mut self) { + self.current.normalize(); + self.allowed = self + .categories + .iter() + .map(|c| self.current.filters.allows(*c)) + .collect(); + self.scope = DependencyScope::default(); + self.canonical = DependencyScope::default(); + self.path = None; + self.path_blocked = false; + let Some(center) = self + .current + .center + .as_deref() + .and_then(|id| self.index.node_index(id)) + else { + return; + }; + self.allowed[center] = true; + self.scope = self + .index + .scope(center, &self.current.options, &self.allowed); + self.canonical = self.index.scope( + center, + &self.current.options, + &vec![true; self.allowed.len()], + ); + if let Some(target) = self + .current + .target + .as_deref() + .and_then(|id| self.index.node_index(id)) + { + self.path = self.index.shortest_path(center, target, &self.allowed); + self.path_blocked = self.path.is_none() + && self + .index + .shortest_path(center, target, &vec![true; self.allowed.len()]) + .is_some(); + } + if let Some(path) = &self.path { + self.scope = self + .index + .include_path(&self.scope, path, self.current.options.budget); + self.canonical = + self.index + .include_path(&self.canonical, path, self.current.options.budget); + } + for id in &self.current.expanded_ids { + if let Some(node) = self.index.node_index(id) { + self.scope = self.index.expand( + center, + &self.scope, + node, + &self.current.options, + &self.allowed, + ); + self.canonical = self.index.expand( + center, + &self.canonical, + node, + &self.current.options, + &vec![true; self.allowed.len()], + ); + } + } + if let Some(path) = &self.path { + self.scope = self + .index + .include_path(&self.scope, path, self.current.options.budget); + self.canonical = + self.index + .include_path(&self.canonical, path, self.current.options.budget); + } + } + pub fn expand(&mut self, id: String) { + if !self.current.expanded_ids.contains(&id) { + self.current.expanded_ids.push(id); + self.rebuild(); + self.record(); + } + } +} +pub fn spatial_mode(state: Option>) -> bool { + state.is_none_or(|s| s.mode == ViewMode::Spatial) +} + +pub fn sync_explorer( + layout: Option>, + epoch: Res, + mut state: ResMut, + settings: Res, + store: Option>, +) { + let Some(layout) = layout else { + return; + }; + if state.epoch == Some(epoch.0) { + return; + } + let first = state.epoch.is_none(); + state.replace_scene(&layout.scene, epoch.0); + if first + && let Some(store) = store + && store.enabled + && let Some(saved) = store + .session + .views + .get(&hyper_viz::view_key(&layout.scene.meta.id)) + .and_then(|v| v.explorer.as_ref()) + { + if state.requested == ViewMode::Auto && saved.mode != ViewMode::Auto { + state.choose_mode(saved.mode); + } + state.restore(saved.snapshot.clone()); + state.history = saved.history.iter().rev().take(32).cloned().collect(); + state.history.reverse(); + state.cursor = saved.cursor.min(state.history.len().saturating_sub(1)); + } + if first && let Some(id) = &settings.initial_module { + state.select_module(id); + } +} +#[cfg(test)] +mod tests { + use super::*; + use hyper_viz::{Hyperedge, Hypergraph, Projection}; + fn scene() -> HypergraphScene { + let mut g = Hypergraph::new() + .vertex("a", "a", "module") + .vertex("ab", "ab", "module") + .vertex("c", "c", "module"); + let mut e = Hyperedge::new("a:ab", ["a", "ab"]).with_kind("import"); + e.attrs.insert("source".into(), "a".into()); + e.attrs.insert("target".into(), "ab".into()); + g.add_hyperedge(e); + g.project(Projection::StarCentroid) + } + #[test] + fn exact_selection_history_and_reload_use_stable_ids() { + let mut s = ExplorerState::default(); + s.replace_scene(&scene(), 0); + assert_eq!(s.search("a")[0], 0); + assert!(s.current.center.is_none()); + s.select_module("a"); + assert_eq!(s.scope.nodes, vec![0, 1]); + s.current.filters.tests = false; + s.expand("ab".into()); + s.select_module("c"); + s.back(); + assert_eq!(s.current.center.as_deref(), Some("a")); + assert!(!s.current.filters.tests); + assert_eq!(s.current.expanded_ids, ["ab"]); + s.forward(); + assert_eq!(s.current.center.as_deref(), Some("c")); + let removed = Hypergraph::new() + .vertex("a", "a", "module") + .project(Projection::StarCentroid); + s.replace_scene(&removed, 1); + assert_eq!(s.current.center.as_deref(), Some("c")); + assert!(s.scope.nodes.is_empty()); + } + #[test] + fn snapshot_sanitizes_nonfinite_values_and_bounds() { + let mut snapshot = ExplorerSnapshot { + pan: [f32::NAN, 2.], + zoom: f32::INFINITY, + ..Default::default() + }; + snapshot.options.budget = 5000; + let mut state = ExplorerState::default(); + state.restore(snapshot); + assert_eq!(state.current.pan, [0., 0.]); + assert_eq!(state.current.zoom, 1.); + assert_eq!(state.current.options.budget, 1000); + } + #[test] + fn explicit_mode_survives_a_scene_reload() { + let mut state = ExplorerState::default(); + state.replace_scene(&scene(), 0); + state.choose_mode(ViewMode::Spatial); + state.replace_scene(&scene(), 1); + assert_eq!(state.mode, ViewMode::Spatial); + } + #[test] + fn filtering_an_intermediate_stops_replayed_expansion() { + let mut graph = Hypergraph::new(); + for id in ["A", "MathlibTest.B", "C", "D"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("A", "MathlibTest.B"), ("MathlibTest.B", "C"), ("C", "D")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("A"); + state.current.options.imports = hyper_viz::TraversalDepth::Hops(2); + state.rebuild(); + state.expand("C".into()); + assert_eq!(state.scope.nodes.len(), 4); + state.current.filters.tests = false; + state.rebuild(); + assert_eq!(state.scope.nodes, [state.index.node_index("A").unwrap()]); + } + #[test] + fn tracing_a_path_adds_steps_beyond_depth_and_discloses_budget_omissions() { + let mut graph = Hypergraph::new(); + for id in ["A", "B", "C", "D"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("A", "B"), ("B", "C"), ("C", "D")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("A"); + state.current.target = Some("D".into()); + state.rebuild(); + assert_eq!(state.scope.nodes.len(), 4); + assert_eq!(state.path.as_ref().unwrap().len(), 4); + state.current.options.budget = 2; + state.rebuild(); + assert_eq!(state.scope.nodes.len(), 2); + assert_eq!(state.scope.omitted, 2); + assert_eq!(state.path.as_ref().unwrap().len(), 4); + } + #[test] + fn displayed_path_modules_can_be_expanded() { + let mut graph = Hypergraph::new(); + for id in ["A", "B", "C", "D"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("A", "B"), ("B", "C"), ("C", "D")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("A"); + state.current.target = Some("C".into()); + state.rebuild(); + assert_eq!(state.scope.nodes.len(), 3); + state.expand("C".into()); + assert_eq!(state.scope.nodes.len(), 4); + } +} diff --git a/crates/hyper-viz-bevy/src/explorer_ui.rs b/crates/hyper-viz-bevy/src/explorer_ui.rs new file mode 100644 index 0000000..0f6fdd3 --- /dev/null +++ b/crates/hyper-viz-bevy/src/explorer_ui.rs @@ -0,0 +1,439 @@ +use crate::{explorer_state::ExplorerState, focus::FocusScope, graph::GraphLayout}; +use bevy::prelude::*; +use bevy_egui::{EguiContexts, egui}; +use bevy_panorbit_camera::PanOrbitCamera; +use hyper_viz::{TraversalDepth, session::ViewMode}; +use std::collections::HashSet; + +fn depth(ui: &mut egui::Ui, label: &str, value: &mut TraversalDepth) { + egui::ComboBox::from_label(label) + .selected_text(match value { + TraversalDepth::Off => "Off".into(), + TraversalDepth::Hops(n) => format!("{n} hop(s)"), + TraversalDepth::Transitive => "Transitive".into(), + }) + .show_ui(ui, |ui| { + ui.selectable_value(value, TraversalDepth::Off, "Off"); + for n in 1..=6 { + ui.selectable_value(value, TraversalDepth::Hops(n), format!("{n} hop(s)")); + } + ui.selectable_value(value, TraversalDepth::Transitive, "Transitive"); + }); +} +pub fn scoped_edges(state: &ExplorerState) -> HashSet { + let nodes: HashSet<_> = state.scope.nodes.iter().copied().collect(); + let mut edges: HashSet<_> = state + .index + .relations() + .iter() + .filter(|r| nodes.contains(&r.source) && nodes.contains(&r.target)) + .flat_map(|r| r.hyperedge_indices.iter().copied()) + .collect(); + edges.extend(state.group); + edges +} +#[allow(clippy::too_many_arguments)] +pub fn explorer_panel( + mut contexts: EguiContexts, + mut state: ResMut, + layout: Res, + mut focus: ResMut, + mut search: Local, + mut target: Local, + mut list: Local, + mut hulls: ResMut, + mut frame: ResMut, +) { + let Ok(ctx) = contexts.ctx_mut() else { + return; + }; + let previous_mode = state.mode; + egui::TopBottomPanel::top("explorer_mode").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.strong("Module explorer"); + ui.selectable_value(&mut state.mode, ViewMode::Dependencies, "Dependencies"); + ui.selectable_value(&mut state.mode, ViewMode::Spatial, "Spatial"); + }); + }); + if state.mode != previous_mode { + state.user_mode = true; + } + if state.mode != ViewMode::Dependencies { + return; + } + let before = state.snapshot(); + let mut selected = None; + let mut target_selected = None; + egui::SidePanel::left("module_controls") + .default_width(330.) + .min_width(280.) + .show(ctx, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal(|ui| { + if ui.button("Back").clicked() { state.back(); } + if ui.button("Forward").clicked() { state.forward(); } + }); + selected = module_picker(ui, "Find a module", &mut search, &mut state, &layout.scene); + ui.separator(); + if let Some(center) = state.current.center.clone() { + ui.strong(¢er); + if let Some(i) = state.index.node_index(¢er) { + ui.label(format!("{} direct imports · {} direct dependents", state.index.imports(i).len(), state.index.dependents(i).len())); + if let Some(path) = layout.scene.nodes[i].attrs.get("path").and_then(|v| v.as_str()) { ui.label(path); } + } else { ui.label("This module is absent from the current graph. Choose another module."); } + } else { ui.label("Search and choose one module to begin."); } + depth(ui, "Imports", &mut state.current.options.imports); + depth(ui, "Dependents", &mut state.current.options.dependents); + ui.horizontal(|ui| { + ui.label("Node budget"); + ui.add(egui::DragValue::new(&mut state.current.options.budget).range(1..=1000)); + }); + ui.collapsing("Include categories", |ui| { + ui.checkbox(&mut state.current.filters.umbrella, "Umbrella modules"); + ui.checkbox(&mut state.current.filters.tests, "Tests"); + ui.checkbox(&mut state.current.filters.tactics, "Tactics"); + ui.checkbox(&mut state.current.filters.external, "External modules"); + }); + ui.label(format!("{} shown · {} budget omitted · {} excluded by filters", state.scope.nodes.len(), state.scope.omitted, state.canonical.total_reachable.saturating_sub(state.scope.total_reachable))); + ui.small("Filters stop traversal; the selected module is always retained."); + ui.horizontal(|ui| { + if ui.button("Reset expansion").clicked() { state.current.expanded_ids.clear(); } + if ui.button("Fit").clicked() { state.fit_requested = true; } + }); + ui.checkbox(&mut list, "List view"); + if ui.button("Inspect displayed scope in Spatial").clicked() { + focus.nodes = Some(state.scope.nodes.iter().copied().collect()); + focus.hyperedges = Some(scoped_edges(&state)); + if state.group.is_some() { hulls.enabled = true; } + frame.pending = true; + state.choose_mode(ViewMode::Spatial); + } + ui.separator(); + target_selected = module_picker(ui, "Shortest import path to", &mut target, &mut state, &layout.scene); + if let Some(target) = &state.current.target { + ui.label(format!("Target: {target}")); + if state.index.node_index(target).is_none() { ui.label("The target is absent from the current graph."); } + } + if let Some(path) = &state.path { + ui.label(format!("{} import hop(s)", path.len().saturating_sub(1))); + let omitted=path.iter().filter(|i|!state.scope.nodes.contains(i)).count(); + if omitted>0 {ui.label(format!("{omitted} path modules omitted from canvas by node budget; full path below."));} + for &i in path { ui.label(&layout.scene.nodes[i].id); } + } else if state.current.target.is_some() { + ui.label(if state.path_blocked { "A path exists, but current filters block it." } else { "No directed import path exists." }); + } + if let Some(center) = state.current.center.as_deref().and_then(|id| state.index.node_index(id)) { + for (label, neighbors) in [("Direct imports", state.index.imports(center).to_vec()), ("Direct dependents", state.index.dependents(center).to_vec())] { + ui.collapsing(format!("{label} ({})", neighbors.len()), |ui| { + for i in neighbors { + ui.horizontal(|ui| { + if ui.selectable_label(false, &layout.scene.nodes[i].id).clicked() { selected = Some(layout.scene.nodes[i].id.clone()); } + if ui.small_button("+1").on_hover_text("Expand this module by one hop").clicked() { state.expand(layout.scene.nodes[i].id.clone()); } + }); + } + }); + } + let center_id = &layout.scene.nodes[center].id; + let exported_id = format!("deps:{center_id}"); + let group = layout.scene.hyperedges.iter().enumerate().find(|(_, e)| e.kind == "dependency_group" && (e.attrs.get("source").and_then(|v| v.as_str()) == Some(center_id) || e.id == exported_id)); + if let Some((i, group)) = group { + ui.collapsing(format!("Exported dependency group ({})", group.member_indices.len()), |ui| { + let mut include = state.group == Some(i); + if ui.checkbox(&mut include, "Include this group in Spatial").changed() { state.group = if include { Some(i) } else { None }; } + if group.member_indices.iter().any(|i| !state.scope.nodes.contains(i)) { ui.small("Its hull appears when all members are in the displayed scope. The full member list follows."); } + for &member in &group.member_indices { ui.label(&layout.scene.nodes[member].id); } + }); + } + } + ui.separator(); + ui.small("Blue: module · Gold: umbrella / center\nGreen: tactic · Pink: test · Purple: external"); + if !state.index.warnings().is_empty() { ui.label(format!("{} malformed imports excluded from directed queries", state.index.warnings().len())); } + }); + }); + if let Some(id) = selected { + state.select_module(&id); + } + if let Some(id) = target_selected { + state.current.target = Some(id); + } + if before != state.current { + state.rebuild(); + state.record(); + } + egui::CentralPanel::default().show(ctx, |ui| { + if state.scope.nodes.is_empty() { + ui.centered_and_justified(|ui| { + ui.label("Choose a module to inspect its dependencies and dependents."); + }); + } else if *list { + egui::ScrollArea::vertical().show(ui, |ui| { + for i in state.scope.nodes.clone() { + ui.horizontal(|ui| { + if ui.button(&layout.scene.nodes[i].id).clicked() { + state.select_module(&layout.scene.nodes[i].id); + } + if ui.button("Expand +1").clicked() { + state.expand(layout.scene.nodes[i].id.clone()); + } + }); + } + }); + } else { + crate::explorer_canvas::canvas(ui, &mut state, &layout.scene); + } + }); +} +fn module_picker( + ui: &mut egui::Ui, + label: &str, + query: &mut String, + state: &mut ExplorerState, + scene: &hyper_viz::HypergraphScene, +) -> Option { + ui.label(label); + let response = ui.add(egui::TextEdit::singleline(query).id(egui::Id::new(label))); + let results = state.search(query).to_vec(); + let mut chosen = None; + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + chosen = results.first().map(|i| scene.nodes[*i].id.clone()); + } + if !query.trim().is_empty() { + if results.is_empty() { + ui.label("No matching module."); + } + for i in results { + if ui.selectable_label(false, &scene.nodes[i].id).clicked() { + chosen = Some(scene.nodes[i].id.clone()); + } + } + } + chosen +} + +#[allow(clippy::type_complexity)] +pub fn spatial_visibility( + state: Res, + mut nodes: Query< + &mut Visibility, + Or<( + With, + With, + )>, + >, + mut cameras: Query<&mut PanOrbitCamera>, + lasso: Res, + mut previous: Local>, + mut running: Local, + mut layout: Option>, +) { + if *previous == Some(state.mode) { + return; + } + let spatial = state.mode == ViewMode::Spatial; + if let Some(ref mut layout) = layout { + if !spatial { + *running = layout.running; + layout.running = false; + } else if previous.is_some() { + layout.running = *running; + } + } + if !spatial { + for mut visibility in &mut nodes { + *visibility = Visibility::Hidden; + } + } else { + for mut visibility in &mut nodes { + if *visibility == Visibility::Hidden { + *visibility = Visibility::Inherited; + } + } + } + for mut camera in &mut cameras { + camera.enabled = spatial && !lasso.enabled; + } + *previous = Some(state.mode); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_egui::{EguiContext, EguiUserTextures, PrimaryEguiContext}; + use hyper_viz::{Hyperedge, Hypergraph, Projection}; + fn input_frame(app: &mut App, ctx: &egui::Context, events: Vec) { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1600., 900.), + )), + events, + ..Default::default() + }); + app.update(); + let _ = ctx.end_pass(); + } + fn type_into(app: &mut App, ctx: &egui::Context, label: &str, text: &str) { + let rect = ctx.read_response(egui::Id::new(label)).unwrap().rect; + let pos = rect.center(); + input_frame( + app, + ctx, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }, + egui::Event::Text(text.into()), + ], + ); + } + fn enter(app: &mut App, ctx: &egui::Context) { + input_frame( + app, + ctx, + vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }], + ); + } + #[test] + fn spatial_transfer_contains_only_displayed_imports_and_chosen_group() { + let mut graph = Hypergraph::new(); + for id in ["a", "b", "c", "d"] { + graph = graph.vertex(id, id, "module"); + } + for (a, b) in [("a", "b"), ("b", "c")] { + let mut edge = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + edge.attrs.insert("source".into(), a.into()); + edge.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(edge); + } + graph = graph.hyperedge("deps:a", ["a", "b"], "deps").hyperedge( + "global", + ["a", "b", "c", "d"], + "global", + ); + let mut state = ExplorerState::default(); + state.replace_scene(&graph.project(Projection::StarCentroid), 0); + state.select_module("a"); + assert_eq!(state.scope.nodes, [0, 1]); + assert_eq!(scoped_edges(&state), HashSet::from([0])); + state.group = Some(2); + assert_eq!(scoped_edges(&state), HashSet::from([0, 2])); + } + #[test] + fn switching_modes_restores_running_state_and_hides_spatial_entities() { + for running in [true, false] { + let scene = Hypergraph::new() + .vertex("a", "a", "module") + .project(Projection::StarCentroid); + let mut layout = + GraphLayout::from_scene(scene, &crate::graph::LayoutSettings::default()); + layout.running = running; + let mut state = ExplorerState::default(); + state.choose_mode(ViewMode::Dependencies); + let mut app = App::new(); + app.insert_resource(layout) + .insert_resource(state) + .init_resource::() + .add_systems(Update, spatial_visibility); + let node = app + .world_mut() + .spawn(( + crate::render::SceneNodeEntity { index: 0 }, + Visibility::Inherited, + )) + .id(); + app.update(); + assert!(!app.world().resource::().running); + assert_eq!( + *app.world().get::(node).unwrap(), + Visibility::Hidden + ); + app.world_mut() + .resource_mut::() + .choose_mode(ViewMode::Spatial); + app.update(); + assert_eq!(app.world().resource::().running, running); + assert_eq!( + *app.world().get::(node).unwrap(), + Visibility::Inherited + ); + } + } + + #[test] + fn actual_panel_selects_one_exact_module_and_traces_beyond_depth() { + let mut graph = Hypergraph::new() + .vertex("a", "a", "module") + .vertex("ab", "ab", "module") + .vertex("c", "c", "module"); + for (a, b) in [("a", "ab"), ("ab", "c")] { + let mut e = Hyperedge::new(format!("{a}:{b}"), [a, b]).with_kind("import"); + e.attrs.insert("source".into(), a.into()); + e.attrs.insert("target".into(), b.into()); + graph.add_hyperedge(e); + } + let scene = graph.project(Projection::StarCentroid); + let mut state = ExplorerState::default(); + state.replace_scene(&scene, 0); + let mut app = App::new(); + app.insert_resource(state) + .insert_resource(GraphLayout::from_scene( + scene, + &crate::graph::LayoutSettings::default(), + )) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(Update, explorer_panel); + let entity = app + .world_mut() + .spawn((EguiContext::default(), PrimaryEguiContext)) + .id(); + let ctx = app + .world_mut() + .get_mut::(entity) + .unwrap() + .get_mut() + .clone(); + input_frame(&mut app, &ctx, Vec::new()); + type_into(&mut app, &ctx, "Find a module", "a"); + assert!( + app.world() + .resource::() + .current + .center + .is_none() + ); + enter(&mut app, &ctx); + assert_eq!( + app.world() + .resource::() + .current + .center + .as_deref(), + Some("a") + ); + assert_eq!(app.world().resource::().scope.nodes.len(), 2); + type_into(&mut app, &ctx, "Shortest import path to", "c"); + enter(&mut app, &ctx); + let state = app.world().resource::(); + assert_eq!(state.path, Some(vec![0, 1, 2])); + assert_eq!(state.scope.nodes.len(), 3); + } +} diff --git a/crates/hyper-viz-bevy/src/focus.rs b/crates/hyper-viz-bevy/src/focus.rs index 1f9564b..1466070 100644 --- a/crates/hyper-viz-bevy/src/focus.rs +++ b/crates/hyper-viz-bevy/src/focus.rs @@ -12,7 +12,7 @@ pub struct AttentionMode { pub on: bool, } -#[derive(Resource, Debug, Clone, Default)] +#[derive(Resource, Debug, Clone, Default, PartialEq, Eq)] pub struct FocusScope { pub nodes: Option>, pub hyperedges: Option>, diff --git a/crates/hyper-viz-bevy/src/graph.rs b/crates/hyper-viz-bevy/src/graph.rs index d957516..22e0a2b 100644 --- a/crates/hyper-viz-bevy/src/graph.rs +++ b/crates/hyper-viz-bevy/src/graph.rs @@ -17,6 +17,9 @@ pub struct GraphLayout { pub scene: HypergraphScene, /// Hyperedge status keyed by hub scene index (avoids O(nodes×edges) scans). pub hub_status: Vec>, + pub last_steps: usize, + pub last_step_ms: f64, + pub positions_revision: u64, } impl GraphLayout { @@ -49,6 +52,8 @@ pub struct LayoutSettings { pub watch: bool, pub projection: Projection, pub input_format: InputFormat, + pub view_mode: hyper_viz::session::ViewMode, + pub initial_module: Option, } /// Published with each replacement scene so renderers cannot consume a stale layout. @@ -75,6 +80,8 @@ impl Default for LayoutSettings { watch: false, projection: Projection::Bipartite, input_format: InputFormat::Auto, + view_mode: hyper_viz::session::ViewMode::Auto, + initial_module: None, } } } @@ -90,12 +97,19 @@ fn layout_from_path( impl GraphLayout { pub fn from_scene(scene: HypergraphScene, settings: &LayoutSettings) -> Self { - let layout = ForceLayout3D::from_scene(&scene, settings.config.clone()); + let large = + scene.vertices_count() >= 1000 && !hyper_viz::DependencyIndex::new(&scene).is_empty(); + let mut config = settings.config.clone(); + if large { + config.gravity = 0.001; + config.centroid_attraction = 0.0005; + } + let layout = ForceLayout3D::from_scene(&scene, config); let link_count = layout.edges.len(); let node_count = scene.node_count(); let hub_status = hub_status_from_scene(&scene); - Self { + let mut result = Self { layout, node_count, link_count, @@ -103,7 +117,14 @@ impl GraphLayout { iterations_per_frame: settings.iterations_per_frame, scene, hub_status, + last_steps: 0, + last_step_ms: 0., + positions_revision: 0, + }; + if large { + seed_subjects(&mut result); } + result } /// Hot-reload: keep force-layout positions for nodes that still exist (matched by stable id). @@ -129,6 +150,9 @@ impl GraphLayout { running: old.running, iterations_per_frame: old.iterations_per_frame, hub_status: hub_status_from_scene(&scene), + last_steps: 0, + last_step_ms: 0., + positions_revision: 0, scene, } } @@ -148,7 +172,10 @@ fn hub_status_from_scene(scene: &HypergraphScene) -> Vec> { pub fn init_graph( mut commands: Commands, - settings: Res, + mut settings: ResMut, + mut hulls: Option>, + mut lines: Option>, + showcase: Option>, initial: Option>, mut watch: Option>, ) { @@ -181,6 +208,15 @@ pub fn init_graph( watch = settings.watch, "Hypergraph scene initialized" ); + settings.config = layout.layout.config.clone(); + if showcase.is_none() && layout.node_count >= 1000 && layout.layout.config.gravity == 0.001 { + if let Some(ref mut hulls) = hulls { + hulls.enabled = false; + } + if let Some(ref mut lines) = lines { + lines.opacity = 0.15; + } + } commands.insert_resource(layout); } @@ -298,20 +334,39 @@ pub fn poll_live_scene( pub fn step_layout(mut layout: ResMut) { if !layout.running { + layout.last_steps = 0; + layout.last_step_ms = 0.; return; } - let iters = if layout.node_count >= 800 { - layout.iterations_per_frame.min(2) - } else if layout.node_count >= 400 { - layout.iterations_per_frame.min(3) - } else { - layout.iterations_per_frame - }; - - for _ in 0..iters { + let start = std::time::Instant::now(); + let mut steps = 0; + for _ in 0..layout.iterations_per_frame.max(1) { layout.layout.step(); + steps += 1; + if start.elapsed().as_secs_f64() >= 0.008 { + break; + } + } + layout.last_steps = steps; + layout.last_step_ms = start.elapsed().as_secs_f64() * 1000.; +} + +/// Stable subject clusters, shared with the interactive large-graph preset. +pub fn seed_subjects(layout: &mut GraphLayout) { + let seed = |s: &str| { + let h = s.bytes().fold(0xcbf29ce484222325_u64, |h, b| { + (h ^ b as u64).wrapping_mul(0x100000001b3) + }); + let c = |shift| (((h >> shift) & 0xffff_u64) as f32 / 32767.5) - 1.; + hyper_viz::Vec3::new(c(0), c(16), c(32)) + }; + for (i, node) in layout.scene.nodes.iter().enumerate() { + layout.layout.positions[i] = seed(&node.kind) * 650. + seed(&node.id) * 90.; + layout.layout.velocities[i] = hyper_viz::Vec3::default(); } + // Mark a position revision for hulls even when the simulation is paused. + layout.positions_revision += 1; } #[cfg(test)] @@ -370,6 +425,61 @@ mod watch_tests { ); } + #[test] + fn tour_initialization_keeps_explicit_line_opacity() { + let mut graph = Hypergraph::new(); + for n in 0..1000 { + let id = n.to_string(); + graph = graph.vertex(&id, &id, "module"); + } + let mut edge = hyper_viz::Hyperedge::new("import", ["0", "1"]).with_kind("import"); + edge.attrs.insert("source".into(), "0".into()); + edge.attrs.insert("target".into(), "1".into()); + graph.add_hyperedge(edge); + let mut app = App::new(); + app.init_resource::() + .insert_resource(crate::InitialScene(graph.project(Projection::StarCentroid))) + .insert_resource(crate::render::LinkRenderSettings { + max_lines: 0, + opacity: 0.10, + }) + .insert_resource(crate::showcase::ShowcaseConfig { + frames: 1, + fps: 30, + warmup: 0, + capture: None, + report: Default::default(), + live_layout: false, + }) + .add_systems(Startup, init_graph); + app.update(); + assert_eq!( + app.world() + .resource::() + .opacity, + 0.10 + ); + } + + #[test] + fn running_layout_steps_at_least_once_and_reports_actual_work() { + let scene = Hypergraph::new() + .vertex("a", "a", "vertex") + .project(Projection::StarCentroid); + let mut layout = GraphLayout::from_scene(scene, &LayoutSettings::default()); + layout.iterations_per_frame = 0; + let mut app = App::new(); + app.insert_resource(layout).add_systems(Update, step_layout); + app.update(); + let layout = app.world().resource::(); + assert_eq!(layout.iterations(), 1); + assert_eq!(layout.last_steps, 1); + assert!(layout.last_step_ms >= 0.); + app.world_mut().resource_mut::().running = false; + app.update(); + assert_eq!(app.world().resource::().last_steps, 0); + } + #[test] fn unchanged_file_stamp_does_not_replace_the_live_scene() { let (_tmp, mut app) = fixture(); diff --git a/crates/hyper-viz-bevy/src/hyperedge_hull.rs b/crates/hyper-viz-bevy/src/hyperedge_hull.rs index b744a99..92e4489 100644 --- a/crates/hyper-viz-bevy/src/hyperedge_hull.rs +++ b/crates/hyper-viz-bevy/src/hyperedge_hull.rs @@ -18,7 +18,7 @@ use crate::render::SceneNodeEntity; const TOPOLOGY_INTERVAL: u32 = 8; const WIREFRAME_ALL_LIMIT: usize = 64; -#[derive(Resource, Debug, Clone)] +#[derive(Resource, Debug, Clone, PartialEq)] pub struct HyperedgeHullSettings { pub enabled: bool, pub opacity: f32, @@ -60,13 +60,19 @@ impl Plugin for HyperedgeHullPlugin { app.init_resource::().add_systems( Update, ( - sync_hyperedge_hulls.run_if(resource_exists::), + sync_hyperedge_hulls + .after(crate::graph::step_layout) + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), draw_hull_wireframes .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode) .run_if(|settings: Res| { settings.enabled && settings.wireframe }), - update_hub_visibility.run_if(resource_exists::), + update_hub_visibility + .run_if(resource_exists::) + .run_if(crate::explorer_state::spatial_mode), ), ); } @@ -94,8 +100,9 @@ fn sync_hyperedge_hulls( focus: Res, time: Res