From ad7fd8fca20d7ffdf9a86de3302288e963bbe6e9 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:43:07 +0200 Subject: [PATCH 01/78] feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: embedding clustering feat: embedding clustering feat: embedding clustering feat: embedding clustering feat: checkpoint feat: checkpoint feat: checkpoint fix: merge feat: checkpoint feat: checkpoint feat: checkpoint fix: merge feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint] feat: checkpoint] feat: checkpoint] feat: checkpoint feat: checkpoint --- libs/@local/graph/api/src/rest/entity/mod.rs | 63 +- .../store/postgres/knowledge/entity/mod.rs | 191 ++- .../graph/store/src/embedding/clustering.rs | 1437 +++++++++++++++++ .../graph/store/src/embedding/dimension.rs | 79 + .../graph/store/src/embedding/kernel.rs | 769 +++++++++ libs/@local/graph/store/src/embedding/mod.rs | 15 + libs/@local/graph/store/src/entity/mod.rs | 16 +- libs/@local/graph/store/src/entity/store.rs | 74 +- libs/@local/graph/store/src/error.rs | 15 + libs/@local/graph/store/src/lib.rs | 5 + libs/@local/graph/type-fetcher/src/store.rs | 20 +- tests/graph/integration/postgres/lib.rs | 11 + 12 files changed, 2663 insertions(+), 32 deletions(-) create mode 100644 libs/@local/graph/store/src/embedding/clustering.rs create mode 100644 libs/@local/graph/store/src/embedding/dimension.rs create mode 100644 libs/@local/graph/store/src/embedding/kernel.rs create mode 100644 libs/@local/graph/store/src/embedding/mod.rs diff --git a/libs/@local/graph/api/src/rest/entity/mod.rs b/libs/@local/graph/api/src/rest/entity/mod.rs index 1be3e54b8de..afc026cf3f1 100644 --- a/libs/@local/graph/api/src/rest/entity/mod.rs +++ b/libs/@local/graph/api/src/rest/entity/mod.rs @@ -13,14 +13,14 @@ use hash_graph_postgres_store::store::error::{EntityDoesNotExist, RaceConditionO use hash_graph_store::{ self, entity::{ - ClosedMultiEntityTypeMap, CreateEntityParams, DiffEntityParams, DiffEntityResult, - EntityPermissions, EntityQueryCursor, EntityQuerySortingRecord, EntityQuerySortingToken, - EntityQueryToken, EntityStore, EntityTypesError, EntityValidationReport, - EntityValidationType, HasPermissionForEntitiesParams, LinkDataStateError, - LinkDataValidationReport, LinkError, LinkTargetError, LinkValidationReport, - LinkedEntityError, MetadataValidationReport, PatchEntityParams, - PropertyMetadataValidationReport, QueryConversion, QueryEntitiesResponse, - SearchEntitiesFilter, SearchEntitiesParams, SearchEntitiesResponse, + ClosedMultiEntityTypeMap, ClusterEntitiesParams, ClusterEntitiesResponse, + CreateEntityParams, DiffEntityParams, DiffEntityResult, EntityCluster, EntityPermissions, + EntityQueryCursor, EntityQuerySortingRecord, EntityQuerySortingToken, EntityQueryToken, + EntityStore, EntityTypesError, EntityValidationReport, EntityValidationType, + HasPermissionForEntitiesParams, LinkDataStateError, LinkDataValidationReport, LinkError, + LinkTargetError, LinkValidationReport, LinkedEntityError, MetadataValidationReport, + PatchEntityParams, PropertyMetadataValidationReport, QueryConversion, + QueryEntitiesResponse, SearchEntitiesFilter, SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UnexpectedEntityType, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, @@ -97,6 +97,7 @@ use crate::rest::{ search_entities, patch_entity, update_entity_embeddings, + cluster_entities, diff_entity, ), components( @@ -114,6 +115,9 @@ use crate::rest::{ Embedding, UpdateEntityEmbeddingsParams, EntityEmbedding, + ClusterEntitiesParams, + ClusterEntitiesResponse, + EntityCluster, EntityQueryToken, PatchEntityParams, @@ -226,7 +230,12 @@ impl EntityResource { .route("/bulk", post(create_entities::)) .route("/diff", post(diff_entity::)) .route("/validate", post(validate_entity::)) - .route("/embeddings", post(update_entity_embeddings::)) + .nest( + "/embeddings", + Router::new() + .route("/", post(update_entity_embeddings::)) + .route("/clusters", post(cluster_entities::)), + ) .route("/permissions", post(has_permission_for_entities::)) .route("/search", post(search_entities::)) .nest( @@ -597,6 +606,42 @@ where .map_err(report_to_response) } +#[utoipa::path( + post, + path = "/entities/embeddings/clusters", + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "Clusters of entities by embedding similarity", body = ClusterEntitiesResponse), + (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), + + (status = 500, description = "Store error occurred"), + ), + request_body = ClusterEntitiesParams, +)] +async fn cluster_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Json(params): Json, +) -> Result, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + store + .cluster_entities(actor_id, params) + .await + .map_err(report_to_response) + .map(Json) +} + #[utoipa::path( post, path = "/entities/diff", diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 3a41b84b8c7..ff70b0a7d00 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2,6 +2,7 @@ mod delete; mod query; mod read; mod summary; + use alloc::borrow::Cow; use core::{borrow::Borrow as _, mem}; use std::collections::{HashMap, HashSet}; @@ -17,18 +18,21 @@ use hash_graph_authorization::policies::{ store::{PolicyCreationParams, PrincipalStore as _}, }; use hash_graph_store::{ + embedding::dimension::Dimension, entity::{ - CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EmptyEntityTypes, - EntityPermissions, EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityStore, - EntityTypeRetrieval, EntityTypesError, EntityValidationReport, EntityValidationType, - HasPermissionForEntitiesParams, PatchEntityParams, QueryConversion, QueryEntitiesParams, - QueryEntitiesResponse, QueryEntitySubgraphParams, QueryEntitySubgraphResponse, - SearchEntitiesFilter, SearchEntitiesParams, SearchEntitiesResponse, - SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, - ValidateEntityComponents, ValidateEntityParams, + ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, + DeletionSummary, EmptyEntityTypes, EntityCluster, EntityPermissions, EntityQueryCursor, + EntityQueryPath, EntityQuerySorting, EntityStore, EntityTypeRetrieval, EntityTypesError, + EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, + PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, + QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SummarizeEntitiesParams, + SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, + ValidateEntityParams, }, entity_type::{EntityTypeStore as _, IncludeEntityTypeOption}, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + }, filter::{ Filter, FilterExpression, FilterExpressionList, Parameter, ParameterList, protection::transform_filter, @@ -2593,6 +2597,175 @@ where Ok(permitted_ids) } + + #[expect(clippy::too_many_lines)] + #[tracing::instrument(skip(self, params))] + async fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: ClusterEntitiesParams, + ) -> Result> { + // 3072 fits in u16; compile-time verified. + const { + assert!(Embedding::DIM <= u16::MAX as usize); + } + #[expect( + clippy::cast_possible_truncation, + reason = "guarded by the const assertion above" + )] + const STORED_DIM: u16 = Embedding::DIM as u16; + + let dim = Dimension::new(params.dimension).ok_or_else(|| { + Report::new(ClusterError::InvalidDimension { + dimension: params.dimension, + }) + })?; + + if dim.get() > STORED_DIM { + return Err(Report::new(ClusterError::DimensionTooLarge { + dimension: dim.get(), + max: STORED_DIM, + })); + } + let truncated_dim = usize::from(dim.get()); + + // Filter to entities the actor is allowed to view. + let permitted = self + .has_permission_for_entities( + AuthenticatedActor::from(actor_id), + HasPermissionForEntitiesParams { + action: ActionName::ViewEntity, + entity_ids: Cow::Borrowed(¶ms.entity_ids), + temporal_axes: QueryTemporalAxesUnresolved::TransactionTime { + pinned: PinnedTemporalAxisUnresolved::new(None), + variable: VariableTemporalAxisUnresolved::new(None, None), + }, + include_drafts: false, + }, + ) + .await + .change_context(ClusterError::Store)?; + + let permitted_ids: Vec = params + .entity_ids + .iter() + .filter(|&id| permitted.contains_key(id)) + .copied() + .collect(); + + let entity_uuids: Vec = permitted_ids.iter().map(|id| id.entity_uuid).collect(); + let web_ids: Vec = permitted_ids.iter().map(|id| id.web_id).collect(); + + // Truncate server-side via `subvector` so postgres only sends + // `truncated_dim`-dimensional vectors over the wire. + let row_stream = self + .as_client() + .query_raw( + &format!( + "SELECT + e.web_id, + e.entity_uuid, + subvector(e.embedding, 1, {truncated_dim})::vector({truncated_dim}) AS \ + embedding + FROM entity_embeddings e + WHERE e.property IS NULL + AND (e.web_id, e.entity_uuid) IN (SELECT unnest($1::uuid[]), \ + unnest($2::uuid[]))" + ), + [ + &web_ids as &(dyn ToSql + Sync), + &entity_uuids as &(dyn ToSql + Sync), + ], + ) + .instrument(tracing::info_span!( + "cluster_entities.embeddings", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(ClusterError::Store)?; + + let mut row_stream = core::pin::pin!(row_stream); + + let mut flat: Vec = Vec::with_capacity(permitted_ids.len() * truncated_dim); + let mut found_ids: Vec = Vec::with_capacity(permitted_ids.len()); + + while let Some(row) = row_stream + .try_next() + .await + .change_context(ClusterError::Store)? + { + let web_id: WebId = row.get(0); + let entity_uuid: EntityUuid = row.get(1); + let embedding: Embedding<'_> = row.get(2); + + flat.extend(embedding.iter()); + + found_ids.push(EntityId { + web_id, + entity_uuid, + draft_id: None, + }); + } + + // Every requested entity not in a cluster goes into + // `missing_embeddings`, whether due to permissions or no embedding. + // Distinguishing the two would leak permission information. + let found_set: HashSet<(WebId, EntityUuid)> = found_ids + .iter() + .map(|id| (id.web_id, id.entity_uuid)) + .collect(); + let missing_embeddings: Vec = params + .entity_ids + .into_iter() + .filter(|id| !found_set.contains(&(id.web_id, id.entity_uuid))) + .collect(); + + if found_ids.is_empty() || params.cluster_count == 0 { + return Ok(ClusterEntitiesResponse { + clusters: Vec::new(), + missing_embeddings, + }); + } + + let config = hash_graph_store::embedding::clustering::Config::for_k_with_seed( + params.cluster_count, + params.seed.unwrap_or_else(|| { + std::time::SystemTime::UNIX_EPOCH + .elapsed() + .map_or(0, |elapsed| { + #[expect( + clippy::cast_possible_truncation, + reason = "seed only needs entropy, truncation is fine" + )] + let seed = elapsed.as_nanos() as u64; + seed + }) + }), + ); + + let result = hash_graph_store::embedding::clustering::cluster(&flat, dim, &config); + + let mut groups: HashMap> = HashMap::new(); + for (index, id) in found_ids.iter().enumerate() { + groups.entry(result.label(index)).or_default().push(*id); + } + + let clusters = groups + .into_iter() + .map(|(cluster_id, entity_ids)| EntityCluster { + cluster_id, + entity_ids, + centroid: result.centroid(cluster_id).to_vec(), + }) + .collect(); + + Ok(ClusterEntitiesResponse { + clusters, + missing_embeddings, + }) + } } #[derive(Debug)] diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs new file mode 100644 index 00000000000..f48da06398a --- /dev/null +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -0,0 +1,1437 @@ +use alloc::borrow::Cow; +use core::{cmp, mem, num::NonZero}; + +use rand::{Rng, RngExt as _, SeedableRng as _}; +use rand_xoshiro::Xoshiro256PlusPlus; +use rayon::prelude::*; + +use super::{dimension::Dimension, kernel}; + +/// Parameters for k-means clustering. +/// +/// Use [`Config::for_k`] or [`Config::for_k_with_seed`] to construct with +/// reasonable defaults, then override individual fields as needed. +pub struct Config { + /// Number of clusters. + pub k: u16, + + /// Maximum Lloyd iterations per run before declaring convergence. + pub max_iters: NonZero, + + /// Number of independent restarts. The run with the lowest inertia wins. + pub n_init: NonZero, + + /// Convergence tolerance: a run stops early when the relative change in + /// inertia between iterations falls below this value. + pub tol: f32, + + /// Maximum number of points sampled during k-means++ seeding. + /// Capped to avoid quadratic seeding cost on very large datasets. + pub sample_cap: usize, + + /// Base seed for the PRNG. Each restart derives its own seed from this. + pub seed: u64, + + /// Number of points processed per batch in the assignment step. + pub chunk: NonZero, +} + +impl Config { + /// Creates a configuration for `k` clusters, drawing the seed from `rng`. + #[must_use] + pub(crate) fn for_k(k: u16, mut rng: impl Rng) -> Self { + Self::for_k_with_seed(k, rng.random()) + } + + /// Creates a configuration for `k` clusters with a fixed seed. + /// + /// Defaults: 30 max iterations, 5 restarts, 1e-4 convergence tolerance, + /// sample cap of min(256k, 8192), chunk size 256. + #[must_use] + pub fn for_k_with_seed(k: u16, seed: u64) -> Self { + Self { + k, + max_iters: const { NonZero::new(30).unwrap() }, + n_init: const { NonZero::new(5).unwrap() }, + tol: 1e-4, + sample_cap: cmp::min(256 * usize::from(k), 8192), + seed, + chunk: const { NonZero::new(256).unwrap() }, + } + } +} + +/// Result of spherical k-means clustering. +/// +/// `centroids` is a flat `k * d` row-major buffer where `d` is the +/// embedding [`Dimension`]. Centroid `i` occupies +/// `centroids[i * d .. (i + 1) * d]`. +pub struct Clustering { + pub dimension: Dimension, + + /// Flat centroid matrix, `k * d` elements in row-major order. + pub centroids: Box<[f32]>, + + /// Cluster assignment for each input point, values in `0..k`. + pub labels: Box<[u16]>, +} + +impl Clustering { + /// Allocates a zeroed clustering for `k` centroids over `n` points. + fn new(k: u16, n: usize, d: Dimension) -> Self { + // SAFETY: All-zero bits are valid for `f32` (IEEE 754 positive zero) + // and for `u16` (the integer 0). `Box::new_zeroed_slice` allocates + // zeroed memory of the correct layout, so `assume_init` is sound. + let centroids: Box<[f32]> = + unsafe { Box::new_zeroed_slice((k as usize) * (d.get() as usize)).assume_init() }; + // SAFETY: All-zero bits are valid for `u16` (the integer 0). + let labels: Box<[u16]> = unsafe { Box::new_zeroed_slice(n).assume_init() }; + + Self { + centroids, + labels, + dimension: d, + } + } + + /// Returns the `D`-dimensional slice for centroid `cluster`. + #[must_use] + pub fn centroid(&self, cluster: u16) -> &[f32] { + &self.centroids[cluster as usize * (self.dimension.get() as usize) + ..(cluster + 1) as usize * (self.dimension.get() as usize)] + } + + /// Returns a mutable `D`-dimensional slice for centroid `cluster`. + fn centroid_mut(&mut self, cluster: u16) -> &mut [f32] { + &mut self.centroids[cluster as usize * (self.dimension.get() as usize) + ..(cluster + 1) as usize * (self.dimension.get() as usize)] + } + + /// Returns the cluster label for point `entity`. + #[must_use] + pub fn label(&self, entity: usize) -> u16 { + self.labels[entity] + } + + /// Returns a mutable reference to the cluster label for point `entity`. + fn label_mut(&mut self, entity: usize) -> &mut u16 { + &mut self.labels[entity] + } +} + +// TODO: I wonder if we can make this allocation less +fn sample_indices(n: usize, m: usize, mut rng: impl Rng) -> Vec { + let mut idx: Vec = (0..n).collect(); + + for i in 0..m { + let j = i + rng.random_range(0..n - i); // partial Fisher–Yates + idx.swap(i, j); + } + + idx.truncate(m); + idx +} + +/// Squared chord distance between a point and a unit centroid. +/// +/// For a unit centroid `c` and a point with inverse norm `inv`, the cosine +/// similarity is `dot(point, c) * inv`. The squared chord distance is +/// `2 - 2 * similarity`, which lies in `[0, 4]` and equals `||u - c||²` +/// when `u` is the unit-normalized point. +/// +/// Returns `0.0` for zero-norm points (`point_inv_norm == 0.0`). +/// +/// This is a squared distance. Do not square it again for D² sampling. +#[inline] +fn squared_chord_distance(dot: f32, point_inv_norm: f32) -> f32 { + if point_inv_norm == 0.0 { + return 0.0; + } + + let similarity = (dot * point_inv_norm).clamp(-1.0, 1.0); + + 2.0_f32.mul_add(-similarity, 2.0).max(0.0) +} + +/// Finds the nearest centroid to `point` and returns its index and spherical +/// distance. +/// +/// # Safety +/// +/// * `point.len() == D` +/// * `centroids.len() == k * D` +/// * `k > 0` +/// * `D` is a multiple of 8 (enforced at compile time by the const generic). +#[inline] +#[must_use] +pub(crate) unsafe fn nearest_centroid( + point: &[f32], + point_inv_norm: f32, + centroids: &[f32], + k: usize, + d: usize, +) -> (u16, f32) { + debug_assert_eq!(point.len(), d); + debug_assert_eq!(centroids.len(), k * d); + debug_assert!(k > 0); + + // SAFETY: the caller guarantees these preconditions. The hints let the + // compiler elide bounds checks on the centroid slicing inside the loop. + unsafe { + core::hint::assert_unchecked(point.len() == d); + core::hint::assert_unchecked(centroids.len() == k * d); + core::hint::assert_unchecked(d.is_multiple_of(8)); + core::hint::assert_unchecked(k > 0); + } + + let mut best = 0; + let mut best_dot = f32::NEG_INFINITY; + + for cluster in 0..k { + let start = cluster * d; + let centroid = ¢roids[start..start + d]; + + // SAFETY: `point` and `centroid` both have length `D`, and `D` is a + // multiple of 8 (guaranteed by Dimension). + let dot = unsafe { kernel::dot(point, centroid) }; + + #[expect( + clippy::cast_possible_truncation, + reason = "k is supposed to be low, and checked as such via the config" + )] + if dot > best_dot { + best = cluster as u16; + best_dot = dot; + } + } + + (best, squared_chord_distance(best_dot, point_inv_norm)) +} + +/// Pre-allocated scratch space for the k-means fitting loop. +/// +/// All buffers are allocated once and reused across restarts to avoid +/// per-iteration allocation overhead. +struct Fit { + k: usize, + m: usize, + d: usize, + + /// Current centroids for this restart, `k * d` elements. + centroids: Box<[f32]>, + /// Best centroids seen across all restarts. + best_centroids: Box<[f32]>, + /// Per-cluster accumulator for centroid recomputation, `k * d` elements. + sums: Box<[f32]>, + /// Per-cluster point count for centroid averaging. + counts: Box<[usize]>, + /// Per-sample-point cluster assignment. + labels: Box<[u16]>, + /// Per-sample-point closest centroid distance (for k-means++ seeding). + closest_distances: Box<[f32]>, + /// Tracks which sample points have been selected as seeds. + selected: Box<[bool]>, + /// Lowest inertia across all restarts. + best_inertia: f32, +} + +impl Fit { + fn new(k: usize, m: usize, d: usize) -> Self { + // SAFETY: all-zero bits are valid for f32 (IEEE 754 +0.0), usize (0), u16 (0), and bool + // (false). `Box::new_zeroed_slice` allocates zeroed memory of the correct layout + // for each type, so `assume_init` is sound in every case. + let centroids = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; + // SAFETY: see above + let best_centroids = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; + // SAFETY: see above + let sums = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; + // SAFETY: see above + let counts = unsafe { Box::<[usize]>::new_zeroed_slice(k).assume_init() }; + // SAFETY: see above + let labels = unsafe { Box::<[u16]>::new_zeroed_slice(m).assume_init() }; + // SAFETY: see above + let closest_distances = unsafe { Box::<[f32]>::new_zeroed_slice(m).assume_init() }; + // SAFETY: see above + let selected = unsafe { Box::<[bool]>::new_zeroed_slice(m).assume_init() }; + let best_inertia = f32::INFINITY; + + Self { + k, + m, + d, + centroids, + best_centroids, + sums, + counts, + labels, + closest_distances, + selected, + best_inertia, + } + } + + fn reset_centroids(&mut self) { + self.centroids.fill(0.0); + } + + fn reset_sums(&mut self) { + self.sums.fill(0.0); + } + + fn reset_counts(&mut self) { + self.counts.fill(0); + } + + fn reset_selected(&mut self) { + self.selected.fill(false); + } + + /// Reinitializes empty clusters from the sample point farthest from + /// its assigned centroid. + /// + /// For each empty cluster, scans the sample to find the point with + /// the largest squared chord distance to its current centroid, copies + /// that point as the new centroid (normalized), and updates the + /// point's label so it won't be picked again for subsequent empty + /// clusters in the same pass. + #[expect( + clippy::cast_possible_truncation, + reason = "cluster index < k, and k originates from Config::k (u16)" + )] + fn reinit_empty_clusters(&mut self, sample: &[f32], sample_inv_norms: &[f32]) -> bool { + let &mut Self { d, k, .. } = self; + + let mut reseeded = false; + + for cluster in 0..k { + if self.counts[cluster] != 0 { + continue; + } + + reseeded = true; + let mut farthest_idx = 0; + let mut farthest_dist = -1.0_f32; + + for (i, (point, &inv_norm)) in sample.chunks_exact(d).zip(sample_inv_norms).enumerate() + { + let label = usize::from(self.labels[i]); + let c_start = label * d; + + // SAFETY: point and centroid both have length `d`, + // a multiple of 8 (guaranteed by Dimension). + let dot = unsafe { kernel::dot(point, &self.centroids[c_start..c_start + d]) }; + let dist = squared_chord_distance(dot, inv_norm); + + if dist > farthest_dist { + farthest_dist = dist; + farthest_idx = i; + } + } + + let point_start = farthest_idx * d; + let centroid_start = cluster * d; + self.centroids[centroid_start..centroid_start + d] + .copy_from_slice(&sample[point_start..point_start + d]); + + // SAFETY: centroid row has length `d`, a multiple of 8. + unsafe { + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); + } + + // Update the label so the next empty cluster picks a different + // point (this point's distance to its new centroid is ~0). + self.labels[farthest_idx] = cluster as u16; + } + + reseeded + } + + /// Runs k-means++ initialization followed by Lloyd iterations on the + /// sample, repeating for `n_init` restarts. The best centroids (lowest + /// inertia) are stored in `self.best_centroids`. + fn run( + &mut self, + sample: &[f32], + chunk: usize, + row_chunk: usize, + sample_inv_norms: &[f32], + mut rng: impl Rng, + config: &Config, + ) { + for _ in 0..config.n_init.get() { + self.reset_centroids(); + self.closest_distances.fill(f32::INFINITY); + self.reset_selected(); + + self.seed_plusplus(sample, sample_inv_norms, &mut rng); + + let inertia = self.lloyd(sample, chunk, row_chunk, sample_inv_norms, config); + + if inertia < self.best_inertia { + self.best_inertia = inertia; + mem::swap(&mut self.best_centroids, &mut self.centroids); + } + } + } + + /// Runs Lloyd iterations on the sample until convergence or `max_iters`. + /// Returns the final inertia (sum of distances to assigned centroids). + fn lloyd( + &mut self, + sample: &[f32], + chunk: usize, + row_chunk: usize, + sample_inv_norms: &[f32], + config: &Config, + ) -> f32 { + let &mut Self { d, k, .. } = self; + let mut previous_inertia = f32::INFINITY; + let mut inertia = f32::INFINITY; + + for _ in 0..config.max_iters.get() { + inertia = sample + .par_chunks(row_chunk) + .zip(sample_inv_norms.par_chunks(chunk)) + .zip(self.labels.par_chunks_mut(chunk)) + .map(|((points, inv_norms), labels)| { + let mut inertia = 0.0; + let count = labels.len(); + + // SAFETY: each parallel chunk pairs `count` labels with + // `count * d` floats of point data and `count` inv_norms. + // `d` is a multiple of 8 (guaranteed by Dimension). + unsafe { + core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(inv_norms.len() == count); + core::hint::assert_unchecked(d.is_multiple_of(8)); + } + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d..i * d + d]; + let p1 = &points[(i + 1) * d..(i + 1) * d + d]; + let p2 = &points[(i + 2) * d..(i + 2) * d + d]; + let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + + // SAFETY: each point length d, centroids length k*d, + // k > 0, d a multiple of 8 (guaranteed by Dimension). + let nearest = + unsafe { kernel::nearest4(p0, p1, p2, p3, &self.centroids, k, d) }; + + let inv = [ + inv_norms[i], + inv_norms[i + 1], + inv_norms[i + 2], + inv_norms[i + 3], + ]; + for m in 0..4 { + labels[i + m] = nearest[m].0; + inertia += squared_chord_distance(nearest[m].1, inv[m]); + } + i += 4; + } + + while i < count { + let point = &points[i * d..i * d + d]; + // SAFETY: point length d, centroids length k*d, k > 0, + // d mult of 8. + let (label, distance) = + unsafe { nearest_centroid(point, inv_norms[i], &self.centroids, k, d) }; + labels[i] = label; + inertia += distance; + i += 1; + } + + inertia + }) + .sum(); + + self.reset_sums(); + self.reset_counts(); + + for ((point, label), inv_norm) in sample + .chunks_exact(d) + .zip(self.labels.iter().copied()) + .zip(sample_inv_norms.iter().copied()) + { + let cluster = usize::from(label); + let start = cluster * d; + + self.counts[cluster] += 1; + + if inv_norm == 0.0 { + continue; + } + + // SAFETY: `sums[start..start + d]` and `point` both have + // length `d`, and `d` is a multiple of 8 (guaranteed by Dimension). + unsafe { + kernel::add_scaled_into(&mut self.sums[start..start + d], point, inv_norm); + } + } + + for cluster in 0..k { + if self.counts[cluster] == 0 { + continue; + } + + let start = cluster * d; + let centroid = &mut self.centroids[start..start + d]; + let sum = &self.sums[start..start + d]; + + // SAFETY: `centroid` and `sum` both have length `D`, and `D` + // is a multiple of 8 (guaranteed by Dimension). + unsafe { + #[expect( + clippy::cast_precision_loss, + reason = "cluster count is bounded by sample_cap (≤8192), well within f32 \ + precision" + )] + let inv_count = 1.0 / self.counts[cluster] as f32; + kernel::scale_into(centroid, sum, inv_count); + } + + // SAFETY: centroid rows have length `D`, and `D` is a + // multiple of 8 (guaranteed by Dimension). + unsafe { + kernel::normalize(centroid); + } + } + + let reseeded = self.reinit_empty_clusters(sample, sample_inv_norms); + + // Skip the convergence check when a cluster was just reseeded: + // the reseeded centroid hasn't had an assignment pass yet, so + // breaking now would waste the reinit. + if !reseeded && previous_inertia.is_finite() { + let relative_change = + (previous_inertia - inertia).abs() / previous_inertia.max(f32::EPSILON); + + if relative_change <= config.tol { + break; + } + } + + previous_inertia = inertia; + } + + inertia + } + + /// k-means++ D² weighted seeding. Picks `k` initial centroids from the + /// sample, each chosen with probability proportional to its squared + /// distance from the nearest already-chosen centroid. + fn seed_plusplus(&mut self, sample: &[f32], sample_inv_norms: &[f32], mut rng: impl Rng) { + let &mut Self { d, k, m, .. } = self; + + let mut restart_rng = Xoshiro256PlusPlus::seed_from_u64(rng.random()); + let mut point = restart_rng.random_range(0..m); + + for cluster in 0..k { + let centroid_start = cluster * d; + let point_start = point * d; + + self.centroids[centroid_start..centroid_start + d] + .copy_from_slice(&sample[point_start..point_start + d]); + + // SAFETY: centroid rows have length `D`, and `D` is a multiple of 8 (guaranteed by + // Dimension). + unsafe { + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); + } + + self.selected[point] = true; + + let centroid = &self.centroids[centroid_start..centroid_start + d]; + + let total: f32 = sample + .par_chunks_exact(d) + .zip(sample_inv_norms.par_iter().copied()) + .zip(self.closest_distances.par_iter_mut()) + .enumerate() + .map(|(index, ((point, inv_norm), closest))| { + if self.selected[index] { + *closest = 0.0; + return 0.0; + } + + // SAFETY: `point` and `centroid` both have length `D`, and + // `D` is a multiple of 8 (guaranteed by Dimension). + let dot = unsafe { kernel::dot(point, centroid) }; + let distance = squared_chord_distance(dot, inv_norm); + + if distance < *closest { + *closest = distance; + } + + *closest + }) + .sum(); + + if cluster + 1 == k { + break; + } + + point = if total.is_finite() && total > 0.0 { + let mut target = restart_rng.random_range(0.0..total); + let mut sampled = self + .closest_distances + .iter() + .rposition(|distance| *distance > 0.0) + .unwrap_or(0); + + for (index, distance) in self.closest_distances.iter().copied().enumerate() { + if distance <= 0.0 { + continue; + } + + target -= distance; + + if target <= 0.0 { + sampled = index; + break; + } + } + + sampled + } else { + let remaining = self.selected.iter().filter(|selected| !**selected).count(); + let mut target = restart_rng.random_range(0..remaining); + let mut sampled = 0; + + for (index, selected) in self.selected.iter().copied().enumerate() { + if selected { + continue; + } + + if target == 0 { + sampled = index; + break; + } + + target -= 1; + } + + sampled + }; + } + } +} + +/// Per-thread accumulator for parallel centroid recomputation. +/// +/// Each rayon task gets its own `Accum`; they are merged via [`Accum::merge`] +/// after the parallel fold completes. +struct Accum { + /// Per-cluster sum of normalized points, `k * d` elements. + sums: Box<[f32]>, + /// Per-cluster point count. + counts: Box<[usize]>, +} + +impl Accum { + fn new(k: usize, d: usize) -> Self { + // SAFETY: all-zero bits are valid for f32 (0.0) and usize (0). `assume_init` is + // sound after `new_zeroed_slice`. + let sums = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; + // SAFETY: see above + let counts = unsafe { Box::<[usize]>::new_zeroed_slice(k).assume_init() }; + Self { sums, counts } + } + + fn merge(mut self, other: &Self, k: usize, d: usize) -> Self { + for cluster in 0..k { + let start = cluster * d; + + self.counts[cluster] += other.counts[cluster]; + + // SAFETY: both cluster sum rows have length `d`, and `d` is a + // multiple of 8 (guaranteed by Dimension). + unsafe { + kernel::add_into( + &mut self.sums[start..start + d], + &other.sums[start..start + d], + ); + } + } + + self + } +} + +/// Assigns all `n` points to their nearest centroid, recomputes centroids +/// from the full population, and re-assigns labels to the final centroids. +/// +/// Uses a parallel fold/reduce: each rayon task accumulates into its own +/// [`Accum`], then results are merged. The final centroids are averaged +/// and normalized in-place. +/// +/// # Safety +/// +/// * `x.len() == n * D` for some `n` +/// * `clustering.centroids.len() == k * D` +/// * `clustering.labels.len() == n` +/// * `k > 0` +/// * `D` is a multiple of 8 (guaranteed by Dimension) +unsafe fn assign(x: &[f32], clustering: &mut Clustering, k: usize, chunk: usize, row_chunk: usize) { + let d = clustering.dimension.get() as usize; + + let full = x + .par_chunks(row_chunk) + .zip(clustering.labels.par_chunks_mut(chunk)) + .fold( + || Accum::new(k, d), + |mut accum, (points, labels)| { + // SAFETY: `cluster` established `x.len() == n * D` and + // `centroids.len() == k * D`. `par_chunks(row_chunk)` with + // `row_chunk = chunk * D` produces chunks where + // `points.len()` is a multiple of `D` and matches `labels.len() * D`. + unsafe { + assign_chunk(&clustering.centroids, k, d, points, labels, &mut accum); + } + + accum + }, + ) + .reduce(|| Accum::new(k, d), |lhs, rhs| lhs.merge(&rhs, k, d)); + + for cluster in 0..k { + if full.counts[cluster] == 0 { + continue; + } + + let start = cluster * d; + + #[expect( + clippy::cast_possible_truncation, + reason = "cluster < k and k originates from Config::k (u16)" + )] + let centroid = clustering.centroid_mut(cluster as u16); + let sum = &full.sums[start..start + d]; + + // SAFETY: centroid and sum both length D, a multiple of 8. + unsafe { + #[expect( + clippy::cast_precision_loss, + reason = "cluster count bounded by n; precision loss acceptable for averaging" + )] + let inv_count = 1.0 / full.counts[cluster] as f32; + kernel::scale_into(centroid, sum, inv_count); + } + // SAFETY: centroid length D, a multiple of 8. + unsafe { + kernel::normalize(centroid); + } + } + + // SAFETY: centroids were just recomputed; same invariants hold. + unsafe { + reassign( + x, + &clustering.centroids, + &mut clustering.labels, + k, + d, + chunk, + row_chunk, + ); + } +} + +/// Processes one parallel chunk of the assignment step: finds the nearest +/// centroid for each point, accumulates normalized points into cluster sums, +/// and records labels. +/// +/// # Safety +/// +/// * `points.len() == labels.len() * d` +/// * `centroids.len() >= k * d` +/// * `d` is a multiple of 8 +/// * `k > 0` +/// * `accum.sums.len() >= k * d` and `accum.counts.len() >= k` +unsafe fn assign_chunk( + centroids: &[f32], + k: usize, + d: usize, + points: &[f32], + labels: &mut [u16], + accum: &mut Accum, +) { + // field path -> disjoint capture of `centroids` only, leaving + // `labels` free for the mutable parallel borrow. + let count = labels.len(); + + // SAFETY: each parallel chunk pairs `count` labels with + // `count * D` floats of point data. `D` is a compile-time + // multiple of 8. + unsafe { + core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(d.is_multiple_of(8)); + } + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d..i * d + d]; + let p1 = &points[(i + 1) * d..(i + 1) * d + d]; + let p2 = &points[(i + 2) * d..(i + 2) * d + d]; + let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + + // SAFETY: each point length D, centroids length k*D, k > 0, D a multiple of 8 (guaranteed + // by Dimension). + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + let ps = [p0, p1, p2, p3]; + + for m in 0..4 { + let label = nearest[m].0; + labels[i + m] = label; + let cluster = usize::from(label); + accum.counts[cluster] += 1; + + let start = cluster * d; + + // SAFETY: point length D, a multiple of 8. + let norm = unsafe { kernel::dot(ps[m], ps[m]).sqrt() }; + if norm == 0.0 { + continue; + } + + // SAFETY: `sums[start..start + D]` and `point` both have length `D`, and `D` is a + // multiple of 8 (guaranteed by Dimension). + unsafe { + kernel::add_scaled_into(&mut accum.sums[start..start + d], ps[m], norm.recip()); + } + } + i += 4; + } + + while i < count { + let point = &points[i * d..i * d + d]; + // SAFETY: point length D, centroids length k*D, k > 0, D mult of 8. + let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k, d) }; + labels[i] = label; + let cluster = usize::from(label); + accum.counts[cluster] += 1; + + let start = cluster * d; + + // SAFETY: point length D. + let norm = unsafe { kernel::dot(point, point).sqrt() }; + if norm != 0.0 { + // SAFETY: `sums[start..start + D]` and `point` both + // have length `D`, and `D` is a multiple of 8. + unsafe { + kernel::add_scaled_into(&mut accum.sums[start..start + d], point, norm.recip()); + } + } + i += 1; + } +} + +/// Processes one parallel chunk of the reassignment step: updates each +/// label to the nearest final centroid. +/// +/// # Safety +/// +/// * `points.len() == labels.len() * d` +/// * `centroids.len() >= k * d` +/// * `d` is a multiple of 8 +/// * `k > 0` +unsafe fn reassign_chunk( + k: usize, + d: usize, + centroids: &[f32], + points: &[f32], + labels: &mut [u16], +) { + let count = labels.len(); + + // SAFETY: each parallel chunk pairs `count` labels with + // `count * D` floats of point data. `D` is a compile-time + // multiple of 8. + unsafe { + core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(d.is_multiple_of(8)); + } + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d..i * d + d]; + let p1 = &points[(i + 1) * d..(i + 1) * d + d]; + let p2 = &points[(i + 2) * d..(i + 2) * d + d]; + let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + + // SAFETY: each point length D, centroids length k*D, k > 0, + // D a multiple of 8 (guaranteed by Dimension). + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + + labels[i] = nearest[0].0; + labels[i + 1] = nearest[1].0; + labels[i + 2] = nearest[2].0; + labels[i + 3] = nearest[3].0; + i += 4; + } + + while i < count { + let point = &points[i * d..i * d + d]; + // SAFETY: point length D, centroids length k*D, k > 0, D mult of 8. + let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k, d) }; + labels[i] = label; + i += 1; + } +} + +/// Re-assigns labels to the nearest final centroid. +/// +/// After centroid recomputation, some boundary points may no longer be +/// nearest to the centroid stored under their label. This pass fixes that. +/// +/// # Safety +/// +/// Same as [`assign`]. +unsafe fn reassign( + x: &[f32], + centroids: &[f32], + labels: &mut [u16], + k: usize, + d: usize, + chunk: usize, + row_chunk: usize, +) { + x.par_chunks(row_chunk) + .zip(labels.par_chunks_mut(chunk)) + .for_each(|(points, labels)| { + // SAFETY: `par_chunks(row_chunk)` with `row_chunk = chunk * D` + // ensures `points.len() == labels.len() * D`. Centroids and k + // are valid from the caller. + unsafe { + reassign_chunk(k, d, centroids, points, labels); + } + }); +} + +/// Runs spherical k-means over a flat row-major embedding matrix. +/// +/// `x` contains `n` points of `dimension` floats each, laid out +/// contiguously. Returns cluster assignments and unit-normalized centroids. +/// +/// # Panics +/// +/// Panics if `x.len()` is not a multiple of `dimension`. +#[must_use] +#[expect(clippy::integer_division_remainder_used, clippy::integer_division)] +pub fn cluster(x: &[f32], dimension: Dimension, config: &Config) -> Clustering { + let d = dimension.get() as usize; + assert!(x.len().is_multiple_of(d)); + + let n = x.len() / d; + let k = cmp::min(config.k, n.saturating_truncate()); + + let mut clustering = Clustering::new(k, n, dimension); + + if k == 0 { + return clustering; + } + + let k = usize::from(k); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(config.seed); + + // 1. subsample (fit on all of n only when n is already small) + let m = config.sample_cap.max(k).min(n); + + let sample = if m == n { + Cow::Borrowed(x) + } else { + let indices = sample_indices(n, m, &mut rng); + let mut sampled = vec![0_f32; m * d]; + + let chunks = sampled.chunks_mut(d); + assert_eq!(chunks.len(), indices.len()); + + for (chunk, index) in chunks.zip(indices) { + chunk.copy_from_slice(&x[index * d..(index + 1) * d]); + } + + Cow::Owned(sampled) + }; + + let sample = sample.as_ref(); + let chunk = config.chunk.get(); + let row_chunk = chunk + .checked_mul(d) + .unwrap_or_else(|| usize::MAX - (usize::MAX % d)) + .max(d); + + let sample_inv_norms: Vec = sample + .par_chunks_exact(d) + .map(|point| { + // SAFETY: every point is a `d`-sized row, and `d` is a multiple of 8 (guaranteed by + // Dimension). + let norm = unsafe { kernel::dot(point, point).sqrt() }; + + if norm > 0.0 { norm.recip() } else { 0.0 } + }) + .collect(); + + // 2. fit on the sample, best of n_init restarts (guards against bad initializations) + let mut fit = Fit::new(k, m, d); + fit.run(sample, chunk, row_chunk, &sample_inv_norms, rng, config); + mem::swap(&mut clustering.centroids, &mut fit.best_centroids); + + // 3. assign points to clusters + // SAFETY: `x.len() == n * d` (asserted above), `clustering.centroids.len() == k * d`, + // `k > 0` (checked above), `d` is a multiple of 8 (guaranteed by Dimension). + unsafe { + assign(x, &mut clustering, k, chunk, row_chunk); + } + + clustering +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::float_cmp, + clippy::integer_division_remainder_used, + reason = "test module: float comparisons are intentional for exact-zero and distance \ + checks; modulo is used in test data construction" + )] + use super::*; + + /// Builds well-separated blob clusters in D-dimensional space. + /// + /// Each blob has a dominant axis so clusters are far apart in cosine + /// space. Returns `(flat_points, ground_truth_labels)`. + #[expect( + clippy::cast_possible_truncation, + reason = "k is small in tests, fits in u16" + )] + fn make_blobs( + points_per_cluster: usize, + k: usize, + seed: u64, + ) -> (Vec, Vec) { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + let n = points_per_cluster * k; + let mut data = vec![0.0_f32; n * D]; + let mut truth = vec![0_u16; n]; + + for c in 0..k { + let axis = c % D; + for p in 0..points_per_cluster { + let idx = c * points_per_cluster + p; + let row = &mut data[idx * D..(idx + 1) * D]; + + row[axis] = 10.0; + for val in row.iter_mut() { + *val += rng.random_range(-0.01..0.01); + } + + truth[idx] = c as u16; + } + } + + (data, truth) + } + + const D: usize = 64; + + fn l2(v: &[f32]) -> f32 { + v.iter().map(|x| x * x).sum::().sqrt() + } + + /// Random unit-norm centroids in `D`-dimensional space. + fn unit_random(k: usize, seed: u64) -> Vec { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + let mut c = vec![0.0_f32; k * D]; + for row in c.chunks_exact_mut(D) { + for v in row.iter_mut() { + *v = rng.random_range(-1.0..1.0); + } + let n = l2(row); + for v in row.iter_mut() { + *v /= n; + } + } + c + } + + /// Brute-force nearest centroid by cosine similarity. + #[expect(clippy::cast_possible_truncation, reason = "k is small in tests")] + fn brute_nearest_cosine(point: &[f32], centroids: &[f32], k: usize) -> u16 { + let pn = l2(point); + let mut best = 0_u16; + let mut best_cos = f32::NEG_INFINITY; + for c in 0..k { + let cent = ¢roids[c * D..(c + 1) * D]; + let d: f32 = point.iter().zip(cent).map(|(a, b)| a * b).sum(); + let cn = l2(cent); + let cos = if pn == 0.0 || cn == 0.0 { + 0.0 + } else { + d / (pn * cn) + }; + if cos > best_cos { + best_cos = cos; + best = c as u16; + } + } + best + } + + /// Computes clustering accuracy using majority-vote label mapping. + /// + /// K-means labels are permutation-invariant, so this assigns each + /// predicted cluster to whichever ground-truth cluster it overlaps + /// most, then counts correctly assigned points. + #[expect( + clippy::cast_precision_loss, + reason = "counts are small test values, well within f64 precision" + )] + fn accuracy(predicted: &[u16], truth: &[u16], k: usize) -> f64 { + let mut votes = vec![vec![0_usize; k]; k]; + for (&pred, &true_label) in predicted.iter().zip(truth) { + votes[pred as usize][true_label as usize] += 1; + } + + let correct: usize = votes + .iter() + .map(|row| row.iter().copied().max().unwrap_or(0)) + .sum(); + + correct as f64 / predicted.len() as f64 + } + + /// Shorthand for [`Dimension::new`] that panics on invalid input. + fn dim(d: u16) -> Dimension { + Dimension::new(d).expect("test dimension must be a positive multiple of 8") + } + + #[test] + fn chord_identical_vectors_is_zero() { + // dot=1.0, inv_norm=1.0 => similarity=1 => distance=0 + assert_eq!(squared_chord_distance(1.0, 1.0), 0.0); + } + + #[test] + fn chord_orthogonal_vectors() { + // dot=0 => similarity=0 => distance=2 + let dist = squared_chord_distance(0.0, 1.0); + assert!((dist - 2.0).abs() < 1e-6, "expected 2.0, got {dist}"); + } + + #[test] + fn chord_opposite_vectors() { + // dot=-1.0 => similarity=-1 => distance=4 + let dist = squared_chord_distance(-1.0, 1.0); + assert!((dist - 4.0).abs() < 1e-6, "expected 4.0, got {dist}"); + } + + #[test] + fn chord_zero_norm_returns_zero() { + assert_eq!(squared_chord_distance(0.5, 0.0), 0.0); + assert_eq!(squared_chord_distance(-0.5, 0.0), 0.0); + } + + #[test] + fn chord_is_non_negative() { + for dot_val in [0.0, 0.5, 1.0, -0.5, -1.0, 2.0, -2.0] { + for inv in [0.0, 0.5, 1.0, 2.0] { + let dist = squared_chord_distance(dot_val, inv); + assert!(dist >= 0.0, "negative for dot={dot_val}, inv={inv}: {dist}"); + } + } + } + + #[test] + fn cluster_empty_input() { + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&[], dim(8), &config); + assert_eq!(result.labels.len(), 0); + assert_eq!(result.centroids.len(), 0); + } + + #[test] + fn cluster_k0() { + let data = vec![1.0_f32; 8]; + let config = Config::for_k_with_seed(0, 42); + let result = cluster(&data, dim(8), &config); + assert_eq!(result.labels.len(), 1); + assert_eq!(result.labels[0], 0); + } + + #[test] + fn cluster_k1_all_same_label() { + let (data, _) = make_blobs::<8>(20, 3, 123); + let config = Config::for_k_with_seed(1, 42); + let result = cluster(&data, dim(8), &config); + + assert_eq!(result.labels.len(), 60); + assert!( + result.labels.iter().all(|&l| l == 0), + "k=1: all labels must be 0" + ); + } + + #[test] + fn cluster_single_point() { + let data = vec![1.0_f32; 16]; + let config = Config::for_k_with_seed(5, 42); + // k clamped to min(k, n) = 1 + let result = cluster(&data, dim(16), &config); + assert_eq!(result.labels.len(), 1); + assert_eq!(result.labels[0], 0); + } + + #[test] + fn cluster_n_less_than_4() { + // n=3 exercises the scalar tail (no nearest4 tiling). + let (data, _) = make_blobs::<8>(1, 3, 99); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(8), &config); + + assert_eq!(result.labels.len(), 3); + let mut seen = [false; 3]; + for &label in &*result.labels { + seen[label as usize] = true; + } + assert!( + seen.iter().all(|&s| s), + "each point should have a unique cluster" + ); + } + + #[test] + fn cluster_n_equals_k() { + let (data, _) = make_blobs::<8>(1, 5, 77); + let config = Config::for_k_with_seed(5, 42); + let result = cluster(&data, dim(8), &config); + + assert_eq!(result.labels.len(), 5); + let mut seen = [false; 5]; + for &label in &*result.labels { + seen[label as usize] = true; + } + assert!( + seen.iter().all(|&s| s), + "n=k: each point should be its own cluster" + ); + } + + #[test] + fn cluster_recovers_well_separated_blobs() { + let (data, truth) = make_blobs::<8>(50, 4, 314); + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&data, dim(8), &config); + + let acc = accuracy(&result.labels, &truth, 4); + assert!( + acc > 0.95, + "expected >95% accuracy on well-separated blobs, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_deterministic_with_same_seed() { + let (data, _) = make_blobs::<8>(30, 3, 555); + + let r1 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 42)); + let r2 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 42)); + + assert_eq!(r1.labels, r2.labels); + assert_eq!(r1.centroids, r2.centroids); + } + + #[test] + fn cluster_different_seeds_may_differ() { + let (data, _) = make_blobs::<8>(30, 3, 555); + + let r1 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 42)); + let r2 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 9999)); + + // Not guaranteed to differ, but with well-separated blobs and + // different seeds the label permutation usually differs. + assert!( + r1.labels != r2.labels, + "different seeds produced identical label vectors (possible but unlikely)" + ); + } + + #[test] + fn cluster_centroids_are_unit_normalized() { + let (data, _) = make_blobs::<8>(40, 4, 222); + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&data, dim(8), &config); + + for c in 0..4_u16 { + let centroid = result.centroid(c); + // SAFETY: centroid has length 8 (= D), a multiple of 8. + let norm = unsafe { kernel::dot(centroid, centroid).sqrt() }; + assert!( + (norm - 1.0).abs() < 1e-5, + "centroid {c} has norm {norm}, expected 1.0" + ); + } + } + + #[test] + fn cluster_labels_in_range() { + let (data, _) = make_blobs::<8>(25, 5, 333); + let config = Config::for_k_with_seed(5, 42); + let result = cluster(&data, dim(8), &config); + + for (i, &label) in result.labels.iter().enumerate() { + assert!(label < 5, "label[{i}] = {label}, expected < 5"); + } + } + + #[test] + fn cluster_labels_nearest_to_assigned_centroid() { + let (data, _) = make_blobs::<8>(30, 3, 444); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(8), &config); + + let k = 3_usize; + let d = 8_usize; + for (i, point) in data.chunks_exact(d).enumerate() { + let assigned = result.labels[i]; + // SAFETY: point and centroid both have length 8 (= D), a multiple of 8. + let assigned_dot = unsafe { kernel::dot(point, result.centroid(assigned)) }; + + #[expect(clippy::cast_possible_truncation, reason = "k=3 fits in u16")] + for c in 0..k as u16 { + // SAFETY: point and centroid both have length 8, a multiple of 8. + let other_dot = unsafe { kernel::dot(point, result.centroid(c)) }; + assert!( + other_dot <= assigned_dot + 1e-5, + "point {i}: assigned to {assigned} (dot={assigned_dot}) but centroid {c} has \ + higher dot={other_dot}" + ); + } + } + } + + #[test] + fn cluster_d32_recovers_blobs() { + let (data, truth) = make_blobs::<32>(40, 3, 888); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(32), &config); + + let acc = accuracy(&result.labels, &truth, 3); + assert!( + acc > 0.95, + "D=32: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_recovers_with_subsampling() { + // n=12000 with sample_cap=1024 exercises the Cow::Owned path. + let (data, truth) = make_blobs::<8>(2000, 6, 21); + let mut config = Config::for_k_with_seed(6, 5); + config.sample_cap = 1024; + let result = cluster(&data, dim(8), &config); + + let acc = accuracy(&result.labels, &truth, 6); + assert!( + acc > 0.95, + "subsampled: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_more_clusters_than_natural_groups() { + // 3 natural groups but k=8: empty clusters keep their seed centroid, + // nothing should be NaN or infinite. + let (data, _) = make_blobs::<8>(400, 3, 31); + let result = cluster(&data, dim(8), &Config::for_k_with_seed(8, 1)); + + assert!( + result.centroids.iter().all(|v| v.is_finite()), + "NaN or infinite centroid" + ); + assert!(result.labels.iter().all(|&l| l < 8)); + } + + #[test] + fn cluster_all_identical_points() { + // Every point identical: D² distances are all zero during seeding, + // which triggers the uniform fallback path. + let n = 100; + let mut data = vec![0.0_f32; n * 8]; + for row in data.chunks_exact_mut(8) { + row[0] = 1.0; + } + let result = cluster(&data, dim(8), &Config::for_k_with_seed(4, 1)); + + assert!(result.centroids.iter().all(|v| v.is_finite())); + assert!(result.labels.iter().all(|&l| l < 4)); + } + + #[test] + fn nearest_centroid_matches_brute_force_cosine() { + let k = 7; + let centroids = unit_random(k, 99); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(100); + + for _ in 0..1000 { + let p: Vec = core::iter::repeat_with(|| rng.random_range(-3.0..3.0)) + .take(D) + .collect(); + let pn = l2(&p); + let inv = if pn > 0.0 { pn.recip() } else { 0.0 }; + + // SAFETY: point has length D=64, centroids has length k*D, + // k > 0, D is a multiple of 8. + let (got, _) = unsafe { nearest_centroid(&p, inv, ¢roids, k, D) }; + assert_eq!( + got, + brute_nearest_cosine(&p, ¢roids, k), + "mismatch for point norm={pn}" + ); + } + } + + #[test] + fn nearest_centroid_argmax_independent_of_inv_norm() { + let k = 5; + let centroids = unit_random(k, 7); + let mut rng = Xoshiro256PlusPlus::seed_from_u64(8); + + for _ in 0..500 { + let p: Vec = core::iter::repeat_with(|| rng.random_range(-2.0..2.0)) + .take(D) + .collect(); + + // SAFETY: point has length D=64, centroids has length k*D, + // k > 0, D is a multiple of 8. + let (a, _) = unsafe { nearest_centroid(&p, 1.0, ¢roids, k, D) }; + // SAFETY: same preconditions. + let (b, _) = unsafe { nearest_centroid(&p, 0.123, ¢roids, k, D) }; + assert_eq!(a, b, "inv_norm must not change the selected centroid"); + } + } + + #[test] + fn cluster_mixed_zero_norm_rows() { + // Some all-zero rows exercise the inv_norm == 0 path in accumulation + // and the squared_chord_distance == 0 return. + let n = 120; + let mut data = vec![0.0_f32; n * 8]; + let mut rng = Xoshiro256PlusPlus::seed_from_u64(7); + for (i, row) in data.chunks_exact_mut(8).enumerate() { + if i % 10 == 0 { + continue; // leave all-zero + } + for v in row.iter_mut() { + *v = rng.random_range(-1.0..1.0); + } + } + let result = cluster(&data, dim(8), &Config::for_k_with_seed(5, 1)); + + assert!(result.centroids.iter().all(|v| v.is_finite())); + assert!(result.labels.iter().all(|&l| l < 5)); + } +} diff --git a/libs/@local/graph/store/src/embedding/dimension.rs b/libs/@local/graph/store/src/embedding/dimension.rs new file mode 100644 index 00000000000..a3a1a31fe94 --- /dev/null +++ b/libs/@local/graph/store/src/embedding/dimension.rs @@ -0,0 +1,79 @@ +use core::num::NonZero; + +/// An embedding vector dimension, guaranteed to be a positive multiple of 8. +/// +/// The multiple-of-8 invariant ensures that the dimension evenly divides into +/// SIMD lanes (8×f32 = `f32x8`), so vectorized kernels can operate without +/// remainder handling. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Dimension(NonZero); + +impl Dimension { + /// Creates a new dimension if `value` is non-zero and a multiple of 8. + /// + /// Returns [`None`] otherwise. + #[must_use] + pub const fn new(value: u16) -> Option { + // not using `?` here because it isn't `const` + let Some(value) = NonZero::new(value) else { + return None; + }; + + if !value.get().is_multiple_of(8) { + return None; + } + + Some(Self(value)) + } + + /// The raw dimension value. + #[must_use] + pub const fn get(self) -> u16 { + self.0.get() + } +} + +pub const D128: Dimension = Dimension(NonZero::new(128).unwrap()); +pub const D256: Dimension = Dimension(NonZero::new(256).unwrap()); +pub const D512: Dimension = Dimension(NonZero::new(512).unwrap()); +pub const D1536: Dimension = Dimension(NonZero::new(1536).unwrap()); +pub const D3072: Dimension = Dimension(NonZero::new(3072).unwrap()); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_multiples_of_8() { + for v in [8, 16, 24, 128, 256, 3072] { + assert!( + Dimension::new(v).is_some(), + "{v} should be a valid dimension" + ); + } + } + + #[test] + fn zero_rejected() { + assert!(Dimension::new(0).is_none()); + } + + #[test] + fn non_multiples_of_8_rejected() { + for v in [1, 2, 3, 4, 5, 6, 7, 9, 10, 15, 17, 100, 3071] { + assert!( + Dimension::new(v).is_none(), + "{v} should not be a valid dimension" + ); + } + } + + #[test] + fn constants_have_correct_values() { + assert_eq!(D128.0.get(), 128); + assert_eq!(D256.0.get(), 256); + assert_eq!(D512.0.get(), 512); + assert_eq!(D1536.0.get(), 1536); + assert_eq!(D3072.0.get(), 3072); + } +} diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/store/src/embedding/kernel.rs new file mode 100644 index 00000000000..0090f63c4c5 --- /dev/null +++ b/libs/@local/graph/store/src/embedding/kernel.rs @@ -0,0 +1,769 @@ +use core::simd::{Simd, f32x8, num::SimdFloat as _}; + +/// Fused multiply-add when the target has native FMA, separate mul+add otherwise. +/// +/// On aarch64, FMA is part of the base NEON instruction set (`fmla`). +/// On x86_64, FMA requires the `fma` target feature (`vfmadd`); without it, +/// `StdFloat::mul_add` falls back to a per-lane `fmaf` libc call which +/// destroys throughput. The non-FMA path uses a plain multiply and add +/// (`vmulps` + `vaddps`) instead. +#[inline(always)] +#[cfg(not(any(target_arch = "aarch64", target_feature = "fma")))] +fn simd_mul_add(lhs: f32x8, rhs: f32x8, acc: f32x8) -> f32x8 { + lhs * rhs + acc +} + +/// See non-FMA variant above for rationale. +#[inline(always)] +#[cfg(any(target_arch = "aarch64", target_feature = "fma"))] +fn simd_mul_add(lhs: f32x8, rhs: f32x8, acc: f32x8) -> f32x8 { + use std::simd::StdFloat as _; + + lhs.mul_add(rhs, acc) +} + +/// Computes the dot product of two equal-length `f32` slices using SIMD. +/// +/// Four independent accumulators are interleaved to saturate FMA throughput: +/// each accumulator feeds a separate dependency chain, hiding the 4-cycle +/// latency of `fmla`/`vfmadd` on typical micro-architectures. +/// +/// # Safety +/// +/// * `lhs.len() == rhs.len()` +/// * Both lengths are multiples of 8. +#[inline] +#[must_use] +pub(crate) unsafe fn dot(lhs: &[f32], rhs: &[f32]) -> f32 { + debug_assert!(lhs.len().is_multiple_of(8) && lhs.len() == rhs.len()); + + // SAFETY: the caller guarantees equal lengths and a multiple of 8. + // These hints let the compiler elide bounds checks in `as_chunks` and + // the subsequent indexing without raw pointer arithmetic. + unsafe { + core::hint::assert_unchecked(lhs.len() == rhs.len()); + core::hint::assert_unchecked(lhs.len().is_multiple_of(8)); + } + + let (lhs, _) = lhs.as_chunks::<8>(); + let (rhs, _) = rhs.as_chunks::<8>(); + + // SAFETY: both original slices have the same length and that length is a + // multiple of 8, so `as_chunks::<8>` produces equal-length chunk slices + // with empty remainders. + unsafe { + core::hint::assert_unchecked(lhs.len() == rhs.len()); + } + + let mut s0 = f32x8::splat(0.0); + let mut s1 = f32x8::splat(0.0); + let mut s2 = f32x8::splat(0.0); + let mut s3 = f32x8::splat(0.0); + + // Unrolled loop: process 4 chunks (32 floats) per iteration. + let mut offset = 0; + while offset + 4 <= lhs.len() { + let Some([l0, l1, l2, l3]) = lhs[offset..offset + 4].as_array() else { + unreachable!() + }; + let Some([r0, r1, r2, r3]) = rhs[offset..offset + 4].as_array() else { + unreachable!() + }; + + s0 = simd_mul_add(Simd::from_slice(l0), Simd::from_slice(r0), s0); + s1 = simd_mul_add(Simd::from_slice(l1), Simd::from_slice(r1), s1); + s2 = simd_mul_add(Simd::from_slice(l2), Simd::from_slice(r2), s2); + s3 = simd_mul_add(Simd::from_slice(l3), Simd::from_slice(r3), s3); + + offset += 4; + } + + // Tail: process remaining 0..3 chunks one at a time. + #[expect(clippy::min_ident_chars)] + while offset < lhs.len() { + let l = &lhs[offset]; + let r = &rhs[offset]; + + s0 = simd_mul_add(Simd::from_slice(l), Simd::from_slice(r), s0); + offset += 1; + } + + (s0 + s1 + s2 + s3).reduce_sum() +} + +/// Adds `src` element-wise into `dst`. +/// +/// # Safety +/// +/// * `dst.len() == src.len()` +/// * Both lengths are multiples of 8. +#[inline] +pub(crate) unsafe fn add_into(dst: &mut [f32], src: &[f32]) { + debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); + + // SAFETY: the caller guarantees equal lengths and a multiple of 8. + unsafe { + core::hint::assert_unchecked(dst.len() == src.len()); + core::hint::assert_unchecked(dst.len().is_multiple_of(8)); + } + + let (dst, _) = dst.as_chunks_mut::<8>(); + let (src, _) = src.as_chunks::<8>(); + + // SAFETY: same reasoning as the pre-chunk hints: equal input lengths + // that are multiples of 8 produce equal chunk counts. + unsafe { core::hint::assert_unchecked(dst.len() == src.len()) } + + for index in 0..dst.len() { + dst[index] = (f32x8::from_slice(&dst[index]) + f32x8::from_slice(&src[index])).to_array(); + } +} + +/// Writes `src * factor` element-wise into `dst`. +/// +/// # Safety +/// +/// * `dst.len() == src.len()` +/// * Both lengths are multiples of 8. +#[inline] +pub(crate) unsafe fn scale_into(dst: &mut [f32], src: &[f32], factor: f32) { + debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); + + // SAFETY: the caller guarantees equal lengths and a multiple of 8. + unsafe { + core::hint::assert_unchecked(dst.len() == src.len()); + core::hint::assert_unchecked(dst.len().is_multiple_of(8)); + } + + let factor = f32x8::splat(factor); + let (dst, _) = dst.as_chunks_mut::<8>(); + let (src, _) = src.as_chunks::<8>(); + + // SAFETY: same reasoning as the pre-chunk hints: equal input lengths + // that are multiples of 8 produce equal chunk counts. + unsafe { core::hint::assert_unchecked(dst.len() == src.len()) } + + for index in 0..dst.len() { + dst[index] = (f32x8::from_slice(&src[index]) * factor).to_array(); + } +} + +/// Scales `value` in-place by `factor`. +/// +/// # Safety +/// +/// * `value.len()` is a multiple of 8. +#[inline] +pub(crate) unsafe fn scale(value: &mut [f32], factor: f32) { + debug_assert!(value.len().is_multiple_of(8)); + + // SAFETY: the caller guarantees a multiple of 8. + unsafe { + core::hint::assert_unchecked(value.len().is_multiple_of(8)); + } + + let factor = f32x8::splat(factor); + let (dst, _) = value.as_chunks_mut::<8>(); + + for dst in dst { + *dst = (f32x8::from_slice(dst) * factor).to_array(); + } +} + +/// Accumulates `src * factor` element-wise into `dst` (`dst += src * factor`). +/// +/// Fuses a scale and add into a single pass, using FMA where available. +/// Avoids the need for a scratch buffer when accumulating normalized vectors. +/// +/// # Safety +/// +/// * `dst.len() == src.len()` +/// * Both lengths are multiples of 8. +#[inline] +pub(crate) unsafe fn add_scaled_into(dst: &mut [f32], src: &[f32], factor: f32) { + debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); + + // SAFETY: the caller guarantees equal lengths and a multiple of 8. + unsafe { + core::hint::assert_unchecked(dst.len() == src.len()); + core::hint::assert_unchecked(dst.len().is_multiple_of(8)); + } + + let factor = f32x8::splat(factor); + let (dst, _) = dst.as_chunks_mut::<8>(); + let (src, _) = src.as_chunks::<8>(); + + // SAFETY: same reasoning as the pre-chunk hints: equal input lengths + // that are multiples of 8 produce equal chunk counts. + unsafe { core::hint::assert_unchecked(dst.len() == src.len()) } + + for index in 0..dst.len() { + let acc = f32x8::from_slice(&dst[index]); + let val = f32x8::from_slice(&src[index]); + dst[index] = simd_mul_add(val, factor, acc).to_array(); + } +} + +/// Normalizes `value` to unit length in-place. +/// +/// If the vector has zero norm, it is left unchanged. +/// +/// # Safety +/// +/// * `value.len()` is a multiple of 8. +#[inline] +pub(crate) unsafe fn normalize(value: &mut [f32]) { + // SAFETY: `dot` requires equal lengths (trivially true, same slice) + // and a multiple of 8 (guaranteed by the caller). + let norm = unsafe { dot(value, value).sqrt() }; + + if norm > 0.0 { + let factor = 1.0 / norm; + // SAFETY: same slice, same length guarantee. + unsafe { + scale(value, factor); + } + } +} + +/// 4 points x 2 centroids. Eight independent accumulators give ILP 8 (enough to +/// saturate FMA throughput); each point chunk feeds 2 FMAs and each centroid +/// chunk feeds 4. Returns `dot[point][centroid]`. +/// +/// Register budget: 8 `f32x8` accumulators. On AVX2 (16 ymm) this leaves room +/// for the 6 operand loads. On NEON each `f32x8` is two 128-bit regs, so the 8 +/// accumulators take 16 of 32 registers; a 4x4 tile (16 accumulators) also fits +/// there if you want more centroid reuse. Either way, check the asm shows the +/// accumulators staying in registers with no stack spills, and that +/// `simd_mul_add` lowered to `vfmadd`/`fmla` and not a `fmaf` call. If the array +/// form ever spills, the manual unroll below is what keeps them in registers. +/// +/// # Safety +/// * all six slices have length `d` +/// * `d` is a multiple of 8 +#[expect( + clippy::inline_always, + reason = "micro-kernel must inline into nearest4 to keep accumulators in registers" +)] +#[inline(always)] +pub(crate) unsafe fn micro_4x2( + p0: &[f32], + p1: &[f32], + p2: &[f32], + p3: &[f32], + c0: &[f32], + c1: &[f32], +) -> [[f32; 2]; 4] { + debug_assert!(p0.len().is_multiple_of(8)); + debug_assert!( + [p1.len(), p2.len(), p3.len(), c0.len(), c1.len()] + .iter() + .all(|&l| l == p0.len()) + ); + + let (p0, _) = p0.as_chunks::<8>(); + let (p1, _) = p1.as_chunks::<8>(); + let (p2, _) = p2.as_chunks::<8>(); + let (p3, _) = p3.as_chunks::<8>(); + let (c0, _) = c0.as_chunks::<8>(); + let (c1, _) = c1.as_chunks::<8>(); + + // SAFETY: the caller guarantees all six slices have equal length `d`, + // and `d` is a multiple of 8. The hints let the compiler prove that + // `as_chunks` produces equal-length chunk slices. + unsafe { + core::hint::assert_unchecked(p0.len() == p1.len()); + core::hint::assert_unchecked(p0.len() == p2.len()); + core::hint::assert_unchecked(p0.len() == p3.len()); + core::hint::assert_unchecked(p0.len() == c0.len()); + core::hint::assert_unchecked(p0.len() == c1.len()); + } + + let mut a00 = f32x8::splat(0.0); + let mut a01 = f32x8::splat(0.0); + let mut a10 = f32x8::splat(0.0); + let mut a11 = f32x8::splat(0.0); + let mut a20 = f32x8::splat(0.0); + let mut a21 = f32x8::splat(0.0); + let mut a30 = f32x8::splat(0.0); + let mut a31 = f32x8::splat(0.0); + + for t in 0..c0.len() { + let v0 = Simd::from_array(c0[t]); + let v1 = Simd::from_array(c1[t]); + let x0 = Simd::from_array(p0[t]); + let x1 = Simd::from_array(p1[t]); + let x2 = Simd::from_array(p2[t]); + let x3 = Simd::from_array(p3[t]); + + // super::simd_mul_add picks the FMA arm per target. + a00 = simd_mul_add(x0, v0, a00); + a01 = simd_mul_add(x0, v1, a01); + a10 = simd_mul_add(x1, v0, a10); + a11 = simd_mul_add(x1, v1, a11); + a20 = simd_mul_add(x2, v0, a20); + a21 = simd_mul_add(x2, v1, a21); + a30 = simd_mul_add(x3, v0, a30); + a31 = simd_mul_add(x3, v1, a31); + } + + [ + [a00.reduce_sum(), a01.reduce_sum()], + [a10.reduce_sum(), a11.reduce_sum()], + [a20.reduce_sum(), a21.reduce_sum()], + [a30.reduce_sum(), a31.reduce_sum()], + ] +} + +/// Finds the nearest centroid for 4 points simultaneously using the +/// [`micro_4x2`] tiled kernel. +/// +/// Returns `(centroid_index, raw_dot_product)` for each of the 4 points. +/// The raw dot product is **not** a distance; the caller must convert via +/// [`squared_chord_distance`](super::clustering::squared_chord_distance) +/// if needed. +/// +/// # Safety +/// +/// * All four point slices have length `d`. +/// * `centroids.len() >= k * d`. +/// * `d` is a multiple of 8. +/// * `k > 0`. +#[inline] +#[must_use] +pub(crate) unsafe fn nearest4( + p0: &[f32], + p1: &[f32], + p2: &[f32], + p3: &[f32], + centroids: &[f32], + k: usize, + d: usize, +) -> [(u16, f32); 4] { + let mut best_dot = [f32::NEG_INFINITY; 4]; + let mut best_idx = [0_u16; 4]; + + // SAFETY: the caller guarantees these preconditions. + unsafe { + core::hint::assert_unchecked(p0.len() == d); + core::hint::assert_unchecked(p0.len() == p1.len()); + core::hint::assert_unchecked(p0.len() == p2.len()); + core::hint::assert_unchecked(p0.len() == p3.len()); + core::hint::assert_unchecked(centroids.len() >= k * d); + core::hint::assert_unchecked(d.is_multiple_of(8)); + core::hint::assert_unchecked(k > 0); + } + + let mut j = 0; + while j + 2 <= k { + // SAFETY: `j + 2 <= k` and `centroids.len() >= k * d`, so both + // slices `[j*d .. (j+2)*d]` are in-bounds. + let c0 = unsafe { centroids.get_unchecked(j * d..j * d + d) }; + // SAFETY: see above. + let c1 = unsafe { centroids.get_unchecked((j + 1) * d..(j + 1) * d + d) }; + + // SAFETY: all six slices have length `d`, a multiple of 8. + let dots = unsafe { micro_4x2(p0, p1, p2, p3, c0, c1) }; + + #[expect( + clippy::cast_possible_truncation, + reason = "k originates from Config::k (u16), so j < k fits in u16" + )] + for m in 0..4 { + if dots[m][0] > best_dot[m] { + best_dot[m] = dots[m][0]; + best_idx[m] = j as u16; + } + if dots[m][1] > best_dot[m] { + best_dot[m] = dots[m][1]; + best_idx[m] = (j + 1) as u16; + } + } + j += 2; + } + + // Handle odd k: one remaining centroid. + if j < k { + let c = ¢roids[j * d..j * d + d]; + let ps = [p0, p1, p2, p3]; + for m in 0..4 { + // SAFETY: point and centroid both have length `d`, a multiple of 8. + let d = unsafe { dot(ps[m], c) }; + #[expect( + clippy::cast_possible_truncation, + reason = "k originates from Config::k (u16)" + )] + if d > best_dot[m] { + best_dot[m] = d; + best_idx[m] = j as u16; + } + } + } + + [ + (best_idx[0], best_dot[0]), + (best_idx[1], best_dot[1]), + (best_idx[2], best_dot[2]), + (best_idx[3], best_dot[3]), + ] +} + +#[cfg(test)] +mod tests { + #![expect(clippy::float_cmp, clippy::integer_division_remainder_used)] + + use super::*; + + /// Scalar dot product for reference. + fn ref_dot(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() + } + + /// Scalar normalize for reference. + fn ref_normalize(v: &mut [f32]) { + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + for x in v { + *x /= norm; + } + } + } + + /// Deterministic test vector: entry `i` gets `(i+1) * scale`. + #[expect(clippy::cast_precision_loss)] + fn ramp(len: usize, factor: f32) -> Vec { + (0..len).map(|i| (i + 1) as f32 * factor).collect() + } + + /// Asserts two f32 values are within relative tolerance, with an absolute + /// floor for values near zero. + fn assert_close(a: f32, b: f32, tol: f32) { + let diff = (a - b).abs(); + let denom = a.abs().max(b.abs()).max(1e-12); + assert!( + diff / denom < tol, + "values differ: {a} vs {b} (diff={diff}, rel={})", + diff / denom + ); + } + + #[test] + fn dot_matches_scalar_d8() { + let a = ramp(8, 1.0); + let b = ramp(8, 0.5); + let expected = ref_dot(&a, &b); + // SAFETY: both slices have length 8, a multiple of 8. + let got = unsafe { dot(&a, &b) }; + assert_close(got, expected, 1e-6); + } + + #[test] + fn dot_matches_scalar_d24() { + // 24 = 3 chunks of 8: the 4-unrolled body runs 0 iterations, + // all 3 chunks go through the tail path. + let a = ramp(24, 0.1); + let b = ramp(24, -0.2); + let expected = ref_dot(&a, &b); + // SAFETY: both slices have length 24, a multiple of 8. + let got = unsafe { dot(&a, &b) }; + assert_close(got, expected, 1e-5); + } + + #[test] + fn dot_matches_scalar_d3072() { + let a = ramp(3072, 0.001); + let b = ramp(3072, -0.002); + let expected = ref_dot(&a, &b); + // SAFETY: both slices have length 3072, a multiple of 8. + let got = unsafe { dot(&a, &b) }; + assert_close(got, expected, 1e-4); + } + + #[test] + fn dot_is_commutative() { + let a = ramp(32, 0.3); + let b = ramp(32, -0.7); + // SAFETY: both slices have length 32, a multiple of 8. + let ab = unsafe { dot(&a, &b) }; + // SAFETY: same slices, reversed. + let ba = unsafe { dot(&b, &a) }; + assert_eq!(ab, ba); + } + + #[test] + fn dot_self_is_squared_norm() { + let a = ramp(16, 0.5); + let expected: f32 = a.iter().map(|x| x * x).sum(); + // SAFETY: both arguments are the same 16-element slice. + let got = unsafe { dot(&a, &a) }; + assert_close(got, expected, 1e-6); + } + + #[test] + fn dot_orthogonal_is_zero() { + let mut a = vec![0.0_f32; 8]; + let mut b = vec![0.0_f32; 8]; + a[0] = 1.0; + b[1] = 1.0; + // SAFETY: both slices have length 8. + let got = unsafe { dot(&a, &b) }; + assert_eq!(got, 0.0); + } + + #[test] + fn add_into_matches_scalar() { + let src = ramp(16, 1.0); + let mut dst = ramp(16, 0.5); + let expected: Vec = dst.iter().zip(&src).map(|(d, s)| d + s).collect(); + // SAFETY: both slices have length 16, a multiple of 8. + unsafe { add_into(&mut dst, &src) } + assert_eq!(dst, expected); + } + + #[test] + fn add_into_zero_is_identity() { + let zeros = vec![0.0_f32; 24]; + let mut dst = ramp(24, 1.0); + let original = dst.clone(); + // SAFETY: both slices have length 24, a multiple of 8. + unsafe { add_into(&mut dst, &zeros) } + assert_eq!(dst, original); + } + + #[test] + fn scale_into_matches_scalar() { + let src = ramp(16, 1.0); + let mut dst = vec![0.0_f32; 16]; + let factor = 2.5; + let expected: Vec = src.iter().map(|x| x * factor).collect(); + // SAFETY: both slices have length 16, a multiple of 8. + unsafe { scale_into(&mut dst, &src, factor) } + assert_eq!(dst, expected); + } + + #[test] + fn scale_into_zero_gives_zeros() { + let src = ramp(8, 1.0); + let mut dst = ramp(8, 999.0); + // SAFETY: both slices have length 8, a multiple of 8. + unsafe { scale_into(&mut dst, &src, 0.0) } + assert!(dst.iter().all(|&x| x == 0.0)); + } + + #[test] + fn scale_into_one_is_copy() { + let src = ramp(16, 0.3); + let mut dst = vec![0.0_f32; 16]; + // SAFETY: both slices have length 16, a multiple of 8. + unsafe { scale_into(&mut dst, &src, 1.0) } + assert_eq!(dst, src); + } + + #[test] + fn scale_matches_scalar() { + let mut v = ramp(16, 1.0); + let factor = -0.5; + let expected: Vec = v.iter().map(|x| x * factor).collect(); + // SAFETY: slice has length 16, a multiple of 8. + unsafe { scale(&mut v, factor) } + assert_eq!(v, expected); + } + + #[test] + fn add_scaled_into_matches_separate_ops() { + let src = ramp(16, 1.0); + let factor = 0.3; + let mut dst = ramp(16, 0.5); + let expected: Vec = dst.iter().zip(&src).map(|(d, s)| d + s * factor).collect(); + + // SAFETY: both slices have length 16, a multiple of 8. + unsafe { add_scaled_into(&mut dst, &src, factor) } + + for (&got, &exp) in dst.iter().zip(&expected) { + assert_close(got, exp, 1e-6); + } + } + + #[test] + fn add_scaled_into_factor_zero_is_identity() { + let src = ramp(8, 100.0); + let mut dst = ramp(8, 1.0); + let original = dst.clone(); + // SAFETY: both slices have length 8, a multiple of 8. + unsafe { add_scaled_into(&mut dst, &src, 0.0) } + assert_eq!(dst, original); + } + + #[test] + fn normalize_produces_unit_norm() { + let mut v = ramp(32, 0.7); + // SAFETY: length 32, a multiple of 8. + unsafe { normalize(&mut v) } + // SAFETY: same slice, length unchanged. + let norm = unsafe { dot(&v, &v).sqrt() }; + assert_close(norm, 1.0, 1e-6); + } + + #[test] + fn normalize_preserves_direction() { + let mut v = ramp(16, 2.0); + let mut ref_v = v.clone(); + ref_normalize(&mut ref_v); + // SAFETY: length 16, a multiple of 8. + unsafe { normalize(&mut v) } + for (&a, &b) in v.iter().zip(&ref_v) { + assert_close(a, b, 1e-6); + } + } + + #[test] + fn normalize_zero_vector_unchanged() { + let mut v = vec![0.0_f32; 8]; + // SAFETY: length 8, a multiple of 8. + unsafe { normalize(&mut v) } + assert!(v.iter().all(|&x| x == 0.0)); + } + + #[test] + fn normalize_already_unit_is_stable() { + let mut v = vec![0.0_f32; 8]; + v[0] = 1.0; + // SAFETY: length 8, a multiple of 8. + unsafe { normalize(&mut v) } + assert_close(v[0], 1.0, 1e-7); + assert!(v[1..].iter().all(|&x| x == 0.0)); + } + + #[test] + fn micro_4x2_matches_individual_dots() { + let d = 16; + let p0 = ramp(d, 0.1); + let p1 = ramp(d, -0.2); + let p2 = ramp(d, 0.3); + let p3 = ramp(d, -0.4); + let c0 = ramp(d, 0.5); + let c1 = ramp(d, -0.6); + + // SAFETY: all 6 slices have length 16, a multiple of 8. + let got = unsafe { micro_4x2(&p0, &p1, &p2, &p3, &c0, &c1) }; + + let expected = [ + [ref_dot(&p0, &c0), ref_dot(&p0, &c1)], + [ref_dot(&p1, &c0), ref_dot(&p1, &c1)], + [ref_dot(&p2, &c0), ref_dot(&p2, &c1)], + [ref_dot(&p3, &c0), ref_dot(&p3, &c1)], + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(g[0], e[0], 1e-5); + assert_close(g[1], e[1], 1e-5); + } + } + + #[test] + fn micro_4x2_d3072() { + let d = 3072; + let p0 = ramp(d, 0.001); + let p1 = ramp(d, -0.001); + let p2 = ramp(d, 0.002); + let p3 = ramp(d, -0.002); + let c0 = ramp(d, 0.001); + let c1 = ramp(d, -0.001); + + // SAFETY: all 6 slices have length 3072, a multiple of 8. + let got = unsafe { micro_4x2(&p0, &p1, &p2, &p3, &c0, &c1) }; + + let expected = [ + [ref_dot(&p0, &c0), ref_dot(&p0, &c1)], + [ref_dot(&p1, &c0), ref_dot(&p1, &c1)], + [ref_dot(&p2, &c0), ref_dot(&p2, &c1)], + [ref_dot(&p3, &c0), ref_dot(&p3, &c1)], + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(g[0], e[0], 1e-3); + assert_close(g[1], e[1], 1e-3); + } + } + + #[test] + fn nearest4_matches_brute_force_even_k() { + let d = 8; + let k = 4; + + // 4 centroids: axis-aligned unit vectors. + let mut centroids = vec![0.0_f32; k * d]; + for i in 0..k { + centroids[i * d + i] = 1.0; + } + + // 4 points, each close to a different centroid. + let mut points: [Vec; 4] = core::array::from_fn(|_| vec![0.0_f32; d]); + for i in 0..4 { + points[i][i] = 10.0; + points[i][(i + 1) % d] = 0.1; + } + + // SAFETY: d=8 (multiple of 8), k=4 > 0, centroids has length k*d, + // all point slices have length d. + let got = unsafe { + nearest4( + &points[0], &points[1], &points[2], &points[3], ¢roids, k, d, + ) + }; + + assert_eq!(got[0].0, 0); + assert_eq!(got[1].0, 1); + assert_eq!(got[2].0, 2); + assert_eq!(got[3].0, 3); + } + + #[test] + fn nearest4_matches_brute_force_odd_k() { + let d = 8; + let k = 3; // odd: exercises the remainder path + + let mut centroids = vec![0.0_f32; k * d]; + for i in 0..k { + centroids[i * d + i] = 1.0; + } + + let mut points: [Vec; 4] = core::array::from_fn(|_| vec![0.0_f32; d]); + points[0][0] = 5.0; + points[1][1] = 5.0; + points[2][2] = 5.0; + points[3][0] = 3.0; // closest to centroid 0 + + // SAFETY: d=8 (multiple of 8), k=3 > 0, centroids has length k*d, + // all point slices have length d. + let got = unsafe { + nearest4( + &points[0], &points[1], &points[2], &points[3], ¢roids, k, d, + ) + }; + + assert_eq!(got[0].0, 0); + assert_eq!(got[1].0, 1); + assert_eq!(got[2].0, 2); + assert_eq!(got[3].0, 0); + } + + #[test] + fn nearest4_k1_all_same() { + let d = 8; + let centroids = ramp(d, 1.0); + let p0 = ramp(d, 0.1); + let p1 = ramp(d, -0.2); + let p2 = ramp(d, 0.3); + let p3 = ramp(d, -0.4); + + // SAFETY: d=8 (multiple of 8), k=1 > 0, centroids has length d, + // all point slices have length d. + let got = unsafe { nearest4(&p0, &p1, &p2, &p3, ¢roids, 1, d) }; + + assert_eq!(got[0].0, 0); + assert_eq!(got[1].0, 0); + assert_eq!(got[2].0, 0); + assert_eq!(got[3].0, 0); + } +} diff --git a/libs/@local/graph/store/src/embedding/mod.rs b/libs/@local/graph/store/src/embedding/mod.rs new file mode 100644 index 00000000000..cefb998fb67 --- /dev/null +++ b/libs/@local/graph/store/src/embedding/mod.rs @@ -0,0 +1,15 @@ +#![expect( + unsafe_code, + dead_code, + clippy::indexing_slicing, + clippy::float_arithmetic, + clippy::min_ident_chars, + clippy::many_single_char_names, + reason = "embedding module is under active development; dead_code is expected until the \ + public API is wired up. Single-char idents (k, n, m, d, x) are standard \ + mathematical notation for clustering." +)] + +pub mod clustering; +pub mod dimension; +pub(crate) mod kernel; diff --git a/libs/@local/graph/store/src/entity/mod.rs b/libs/@local/graph/store/src/entity/mod.rs index f1174879703..df563a365cc 100644 --- a/libs/@local/graph/store/src/entity/mod.rs +++ b/libs/@local/graph/store/src/entity/mod.rs @@ -4,14 +4,14 @@ pub use self::{ EntityQuerySortingToken, EntityQueryToken, }, store::{ - ClosedMultiEntityTypeMap, CreateEntityParams, DeleteEntitiesParams, DeletionScope, - DeletionSummary, DiffEntityParams, DiffEntityResult, EntityPermissions, EntityStore, - EntityValidationType, HasPermissionForEntitiesParams, LinkDeletionBehavior, - PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, - QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SearchEntitiesFilter, - SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, - SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, - ValidateEntityError, ValidateEntityParams, + ClosedMultiEntityTypeMap, ClusterEntitiesParams, ClusterEntitiesResponse, + CreateEntityParams, DeleteEntitiesParams, DeletionScope, DeletionSummary, DiffEntityParams, + DiffEntityResult, EntityCluster, EntityPermissions, EntityStore, EntityValidationType, + HasPermissionForEntitiesParams, LinkDeletionBehavior, PatchEntityParams, QueryConversion, + QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, + QueryEntitySubgraphResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, + UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityError, + ValidateEntityParams, }, validation_report::{ EmptyEntityTypes, EntityRetrieval, EntityTypeRetrieval, EntityTypesError, diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 94ff152a36b..1af2a5af1c4 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -36,7 +36,9 @@ use utoipa::{ use crate::{ entity::{EntityQueryCursor, EntityQuerySorting, EntityValidationReport}, entity_type::{EntityTypeResolveDefinitions, IncludeEntityTypeOption}, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + }, filter::{Filter, SemanticDistance}, subgraph::{ Subgraph, @@ -525,6 +527,55 @@ impl PatchEntityParams { } } +#[derive(Debug, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClusterEntitiesParams { + pub entity_ids: Vec, + /// Desired number of clusters. Clamped to the number of entities with + /// embeddings when that is smaller. + pub cluster_count: u16, + /// Embedding dimension after matryoshka truncation. Must be a positive + /// multiple of 8; values above 3072 are rejected. Defaults to 256. + #[serde(default = "ClusterEntitiesParams::default_dimension")] + pub dimension: u16, + + /// Seed for the random number generator used in clustering. + /// + /// If not provided, a random seed will be used. + pub seed: Option, +} + +impl ClusterEntitiesParams { + const fn default_dimension() -> u16 { + 256 + } +} + +/// One cluster from a spherical k-means run over entity embeddings. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct EntityCluster { + /// Index in `0..cluster_count`. + pub cluster_id: u16, + pub entity_ids: Vec, + /// Unit-normalized centroid with length equal to the requested dimension. + pub centroid: Vec, +} + +/// Result of [`EntityStore::cluster_entities`]. +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ClusterEntitiesResponse { + /// One entry per non-empty cluster. Empty clusters (no points assigned) + /// are omitted. + pub clusters: Vec, + /// Entities from the request that had no stored embedding. + pub missing_embeddings: Vec, +} + #[derive(Debug, Deserialize)] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -912,6 +963,27 @@ pub trait EntityStore { params: UpdateEntityEmbeddingsParams<'_>, ) -> impl Future>> + Send; + /// Groups entities by embedding similarity using spherical k-means. + /// + /// Each entity's combined embedding is truncated to the requested + /// dimension (matryoshka encoding) before clustering. The returned + /// centroids are unit-normalized and have the same dimension. + /// + /// Entities without a stored embedding are not clustered; they appear + /// in [`ClusterEntitiesResponse::missing_embeddings`]. + /// + /// # Errors + /// + /// Returns [`ClusterError::InvalidDimension`] if the dimension is not a + /// positive multiple of 8, [`ClusterError::DimensionTooLarge`] if it + /// exceeds the stored embedding width, or [`ClusterError::Store`] if the + /// embedding query fails. + fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: ClusterEntitiesParams, + ) -> impl Future>> + Send; + /// Re-indexes the cache for entities. /// /// This is only needed if the entity was changed in place without an update procedure. This is diff --git a/libs/@local/graph/store/src/error.rs b/libs/@local/graph/store/src/error.rs index d9f39229103..fc40ef8daf4 100644 --- a/libs/@local/graph/store/src/error.rs +++ b/libs/@local/graph/store/src/error.rs @@ -68,3 +68,18 @@ pub enum CheckPermissionError { } impl Error for CheckPermissionError {} + +/// Failure to cluster entities by embedding similarity. +#[derive(Debug, derive_more::Display)] +#[display("Could not cluster entities: {_variant}")] +#[must_use] +pub enum ClusterError { + #[display("dimension {dimension} is not a positive multiple of 8")] + InvalidDimension { dimension: u16 }, + #[display("dimension {dimension} exceeds stored embedding dimension {max}")] + DimensionTooLarge { dimension: u16, max: u16 }, + #[display("embedding query failed")] + Store, +} + +impl Error for ClusterError {} diff --git a/libs/@local/graph/store/src/lib.rs b/libs/@local/graph/store/src/lib.rs index 2931d3f0eca..668c46b20b8 100644 --- a/libs/@local/graph/store/src/lib.rs +++ b/libs/@local/graph/store/src/lib.rs @@ -5,6 +5,10 @@ #![feature( // Language Features impl_trait_in_assoc_type, + + // Library Features, + portable_simd, + integer_widen_truncate, )] #![cfg_attr(test, feature( // Language Features @@ -23,6 +27,7 @@ pub mod oauth_provider; pub mod property_type; pub mod user_deletion; +pub mod embedding; pub mod error; pub mod filter; pub mod migration; diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index 99a3ad3415c..d310af8f9a6 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -35,10 +35,10 @@ use hash_graph_store::{ UnarchiveDataTypeParams, UpdateDataTypeEmbeddingParams, UpdateDataTypesParams, }, entity::{ - CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EntityStore, - EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, - QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, - QueryEntitySubgraphResponse, SearchEntitiesParams, SearchEntitiesResponse, + ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, + DeletionSummary, EntityStore, EntityValidationReport, HasPermissionForEntitiesParams, + PatchEntityParams, QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, + QueryEntitySubgraphResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityParams, }, @@ -50,7 +50,9 @@ use hash_graph_store::{ QueryEntityTypesResponse, SearchEntityTypesParams, SearchEntityTypesResponse, UnarchiveEntityTypeParams, UpdateEntityTypeEmbeddingParams, UpdateEntityTypesParams, }, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + }, filter::{Filter, QueryRecord}, pool::StorePool, property_type::{ @@ -1713,6 +1715,14 @@ where self.store.update_entity_embeddings(actor_id, params).await } + async fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: ClusterEntitiesParams, + ) -> Result> { + self.store.cluster_entities(actor_id, params).await + } + async fn reindex_entity_cache(&mut self) -> Result<(), Report> { self.store.reindex_entity_cache().await } diff --git a/tests/graph/integration/postgres/lib.rs b/tests/graph/integration/postgres/lib.rs index 5c2ab899e3f..f9e54f423bb 100644 --- a/tests/graph/integration/postgres/lib.rs +++ b/tests/graph/integration/postgres/lib.rs @@ -890,6 +890,17 @@ impl EntityStore for DatabaseApi<'_> { self.store.reindex_entity_cache().await } + async fn cluster_entities( + &self, + actor_id: ActorEntityUuid, + params: hash_graph_store::entity::ClusterEntitiesParams, + ) -> Result< + hash_graph_store::entity::ClusterEntitiesResponse, + Report, + > { + self.store.cluster_entities(actor_id, params).await + } + async fn has_permission_for_entities( &self, authenticated_actor: AuthenticatedActor, From 4c7c562178b561f5ccd8ba555343393666b7d95d Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:07:07 +0200 Subject: [PATCH 02/78] fix: suggestions from code review --- Cargo.lock | 12 +++++++ Cargo.toml | 1 + .../store/postgres/knowledge/entity/mod.rs | 32 ++++++++----------- libs/@local/graph/store/Cargo.toml | 3 ++ .../graph/store/src/embedding/dimension.rs | 6 ++++ libs/@local/graph/store/src/entity/mod.rs | 3 +- libs/@local/graph/store/src/entity/store.rs | 7 ++-- libs/@local/graph/store/src/error.rs | 6 ++-- libs/@local/graph/type-fetcher/src/store.rs | 2 +- 9 files changed, 46 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0d06b1b2d3..375c424b10a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3815,6 +3815,9 @@ dependencies = [ "hash-temporal-client", "insta", "postgres-types", + "rand 0.10.1", + "rand_xoshiro", + "rayon", "serde", "serde_json", "simple-mermaid", @@ -8032,6 +8035,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_xoshiro" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rapidfuzz" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index e97c0971742..49b388cbcf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -224,6 +224,7 @@ quote = { version = "1.0.41", default-features = fa rand = { version = "0.10.0", default-features = false } rand_core = { version = "0.10.0", default-features = false } rand_distr = { version = "0.6.0", default-features = false } +rand_xoshiro = { version = "0.8.1" } rapidfuzz = { version = "0.5.0", default-features = false } ratatui = { version = "0.30.0" } rayon = { version = "1.11.0", default-features = false } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index ff70b0a7d00..1f7b3b61f78 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -3,7 +3,7 @@ mod query; mod read; mod summary; -use alloc::borrow::Cow; +use alloc::{borrow::Cow, collections::BTreeMap}; use core::{borrow::Borrow as _, mem}; use std::collections::{HashMap, HashSet}; @@ -25,7 +25,8 @@ use hash_graph_store::{ EntityQueryPath, EntityQuerySorting, EntityStore, EntityTypeRetrieval, EntityTypesError, EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, - QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SummarizeEntitiesParams, + QueryEntitySubgraphParams, QueryEntitySubgraphResponse, SearchEntitiesFilter, + SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityParams, }, @@ -2598,36 +2599,31 @@ where Ok(permitted_ids) } - #[expect(clippy::too_many_lines)] + #[expect(clippy::too_many_lines, clippy::cast_possible_truncation)] #[tracing::instrument(skip(self, params))] async fn cluster_entities( &self, actor_id: ActorEntityUuid, params: ClusterEntitiesParams, ) -> Result> { - // 3072 fits in u16; compile-time verified. - const { - assert!(Embedding::DIM <= u16::MAX as usize); - } - #[expect( - clippy::cast_possible_truncation, - reason = "guarded by the const assertion above" - )] + const { assert!(Embedding::DIM <= u16::MAX as usize) }; const STORED_DIM: u16 = Embedding::DIM as u16; - let dim = Dimension::new(params.dimension).ok_or_else(|| { + let dimension = Dimension::new(params.dimension.get()).ok_or_else(|| { Report::new(ClusterError::InvalidDimension { dimension: params.dimension, }) + .attach(StatusCode::InvalidArgument) })?; - if dim.get() > STORED_DIM { + if dimension.get() > STORED_DIM { return Err(Report::new(ClusterError::DimensionTooLarge { - dimension: dim.get(), + dimension: dimension.value(), max: STORED_DIM, - })); + }) + .attach(StatusCode::InvalidArgument)); } - let truncated_dim = usize::from(dim.get()); + let truncated_dim = usize::from(dimension.get()); // Filter to entities the actor is allowed to view. let permitted = self @@ -2745,9 +2741,9 @@ where }), ); - let result = hash_graph_store::embedding::clustering::cluster(&flat, dim, &config); + let result = hash_graph_store::embedding::clustering::cluster(&flat, dimension, &config); - let mut groups: HashMap> = HashMap::new(); + let mut groups: BTreeMap> = BTreeMap::new(); for (index, id) in found_ids.iter().enumerate() { groups.entry(result.label(index)).or_default().push(*id); } diff --git a/libs/@local/graph/store/Cargo.toml b/libs/@local/graph/store/Cargo.toml index 825638c1e35..c02ac60c582 100644 --- a/libs/@local/graph/store/Cargo.toml +++ b/libs/@local/graph/store/Cargo.toml @@ -29,6 +29,9 @@ bytes = { workspace = true, optional = true } derive-where = { workspace = true } derive_more = { workspace = true, features = ["display", "error"] } futures = { workspace = true } +rand = { workspace = true } +rand_xoshiro = { workspace = true } +rayon = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } simple-mermaid = { workspace = true } diff --git a/libs/@local/graph/store/src/embedding/dimension.rs b/libs/@local/graph/store/src/embedding/dimension.rs index a3a1a31fe94..0ba85516ee3 100644 --- a/libs/@local/graph/store/src/embedding/dimension.rs +++ b/libs/@local/graph/store/src/embedding/dimension.rs @@ -31,6 +31,12 @@ impl Dimension { pub const fn get(self) -> u16 { self.0.get() } + + /// The raw dimension value as a [`NonZero`]. + #[must_use] + pub const fn value(self) -> NonZero { + self.0 + } } pub const D128: Dimension = Dimension(NonZero::new(128).unwrap()); diff --git a/libs/@local/graph/store/src/entity/mod.rs b/libs/@local/graph/store/src/entity/mod.rs index df563a365cc..e9aa8161500 100644 --- a/libs/@local/graph/store/src/entity/mod.rs +++ b/libs/@local/graph/store/src/entity/mod.rs @@ -9,7 +9,8 @@ pub use self::{ DiffEntityResult, EntityCluster, EntityPermissions, EntityStore, EntityValidationType, HasPermissionForEntitiesParams, LinkDeletionBehavior, PatchEntityParams, QueryConversion, QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, - QueryEntitySubgraphResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, + QueryEntitySubgraphResponse, SearchEntitiesFilter, SearchEntitiesParams, + SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityComponents, ValidateEntityError, ValidateEntityParams, }, diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 1af2a5af1c4..91bccfdf907 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use core::num::NonZero; use std::collections::{HashMap, HashSet}; use error_stack::Report; @@ -538,7 +539,7 @@ pub struct ClusterEntitiesParams { /// Embedding dimension after matryoshka truncation. Must be a positive /// multiple of 8; values above 3072 are rejected. Defaults to 256. #[serde(default = "ClusterEntitiesParams::default_dimension")] - pub dimension: u16, + pub dimension: NonZero, /// Seed for the random number generator used in clustering. /// @@ -547,8 +548,8 @@ pub struct ClusterEntitiesParams { } impl ClusterEntitiesParams { - const fn default_dimension() -> u16 { - 256 + const fn default_dimension() -> NonZero { + const { NonZero::new(256).unwrap() } } } diff --git a/libs/@local/graph/store/src/error.rs b/libs/@local/graph/store/src/error.rs index fc40ef8daf4..3f1b684167e 100644 --- a/libs/@local/graph/store/src/error.rs +++ b/libs/@local/graph/store/src/error.rs @@ -1,4 +1,4 @@ -use core::{error::Error, fmt}; +use core::{error::Error, fmt, num::NonZero}; #[derive(Debug)] #[must_use] @@ -75,9 +75,9 @@ impl Error for CheckPermissionError {} #[must_use] pub enum ClusterError { #[display("dimension {dimension} is not a positive multiple of 8")] - InvalidDimension { dimension: u16 }, + InvalidDimension { dimension: NonZero }, #[display("dimension {dimension} exceeds stored embedding dimension {max}")] - DimensionTooLarge { dimension: u16, max: u16 }, + DimensionTooLarge { dimension: NonZero, max: u16 }, #[display("embedding query failed")] Store, } diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index d310af8f9a6..87a124b74b0 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -38,7 +38,7 @@ use hash_graph_store::{ ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EntityStore, EntityValidationReport, HasPermissionForEntitiesParams, PatchEntityParams, QueryEntitiesParams, QueryEntitiesResponse, QueryEntitySubgraphParams, - QueryEntitySubgraphResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, + QueryEntitySubgraphResponse, SearchEntitiesParams, SearchEntitiesResponse, SummarizeEntitiesParams, SummarizeEntitiesResponse, UpdateEntityEmbeddingsParams, ValidateEntityParams, }, From c2c072824b7768e2ac609d997081656f6de41845 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:13:16 +0200 Subject: [PATCH 03/78] fix: spawn blocking for clustering --- .../src/store/postgres/knowledge/entity/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 1f7b3b61f78..e850d1e14f6 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2741,7 +2741,11 @@ where }), ); - let result = hash_graph_store::embedding::clustering::cluster(&flat, dimension, &config); + let result = tokio::task::spawn_blocking(move || { + hash_graph_store::embedding::clustering::cluster(&flat, dimension, &config) + }) + .await + .change_context(ClusterError::Store)?; let mut groups: BTreeMap> = BTreeMap::new(); for (index, id) in found_ids.iter().enumerate() { From a63a05a0cf811cac82596fc9073f65cc8438c0ea Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:14:49 +0200 Subject: [PATCH 04/78] fix: regenerate --- libs/@local/graph/api/openapi/openapi.json | 135 +++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 07d1ae085ab..1f224711a82 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -1580,6 +1580,54 @@ } } }, + "/entities/embeddings/clusters": { + "post": { + "tags": [ + "Graph", + "Entity" + ], + "operationId": "cluster_entities", + "parameters": [ + { + "name": "X-Authenticated-User-Actor-Id", + "in": "header", + "description": "The ID of the actor which is used to authorize the request", + "required": true, + "schema": { + "$ref": "#/components/schemas/ActorEntityUuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterEntitiesParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Clusters of entities by embedding similarity", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterEntitiesResponse" + } + } + } + }, + "422": { + "description": "Provided request body is invalid" + }, + "500": { + "description": "Store error occurred" + } + } + } + }, "/entities/permissions": { "post": { "tags": [ @@ -3742,6 +3790,62 @@ "propertyName": "kind" } }, + "ClusterEntitiesParams": { + "type": "object", + "required": [ + "entityIds", + "clusterCount" + ], + "properties": { + "clusterCount": { + "type": "integer", + "format": "int32", + "description": "Desired number of clusters. Clamped to the number of entities with\nembeddings when that is smaller.", + "minimum": 0 + }, + "dimension": { + "$ref": "#/components/schemas/NonZero" + }, + "entityIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityId" + } + }, + "seed": { + "type": "integer", + "format": "int64", + "description": "Seed for the random number generator used in clustering.\n\nIf not provided, a random seed will be used.", + "nullable": true, + "minimum": 0 + } + }, + "additionalProperties": false + }, + "ClusterEntitiesResponse": { + "type": "object", + "description": "Result of [`EntityStore::cluster_entities`].", + "required": [ + "clusters", + "missingEmbeddings" + ], + "properties": { + "clusters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityCluster" + }, + "description": "One entry per non-empty cluster. Empty clusters (no points assigned)\nare omitted." + }, + "missingEmbeddings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityId" + }, + "description": "Entities from the request that had no stored embedding." + } + } + }, "CommonQueryEntityTypesParams": { "type": "object", "required": [ @@ -4507,6 +4611,37 @@ } } }, + "EntityCluster": { + "type": "object", + "description": "One cluster from a spherical k-means run over entity embeddings.", + "required": [ + "clusterId", + "entityIds", + "centroid" + ], + "properties": { + "centroid": { + "type": "array", + "items": { + "type": "number", + "format": "float" + }, + "description": "Unit-normalized centroid with length equal to the requested dimension." + }, + "clusterId": { + "type": "integer", + "format": "int32", + "description": "Index in `0..cluster_count`.", + "minimum": 0 + }, + "entityIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityId" + } + } + } + }, "EntityDeletionProvenance": { "type": "object", "required": [ From c1c0f7feda83491a0f9e30372ae445dfb7879a5c Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:22:47 +0200 Subject: [PATCH 05/78] fix: unnest sql query --- .../postgres-store/src/store/postgres/knowledge/entity/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index e850d1e14f6..50311503127 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2665,8 +2665,8 @@ where embedding FROM entity_embeddings e WHERE e.property IS NULL - AND (e.web_id, e.entity_uuid) IN (SELECT unnest($1::uuid[]), \ - unnest($2::uuid[]))" + AND (e.web_id, e.entity_uuid) IN (SELECT * FROM unnest($1::uuid[], \ + $2::uuid[]))" ), [ &web_ids as &(dyn ToSql + Sync), From 3b5b26e10fe73f274fa6ea8a0aeb946cb34c7504 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:31:18 +0200 Subject: [PATCH 06/78] fix: lints --- libs/@local/graph/store/src/embedding/clustering.rs | 11 ----------- libs/@local/graph/store/src/embedding/kernel.rs | 11 ++++++----- libs/@local/graph/store/src/embedding/mod.rs | 6 ++---- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index f48da06398a..45839919a68 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -37,12 +37,6 @@ pub struct Config { } impl Config { - /// Creates a configuration for `k` clusters, drawing the seed from `rng`. - #[must_use] - pub(crate) fn for_k(k: u16, mut rng: impl Rng) -> Self { - Self::for_k_with_seed(k, rng.random()) - } - /// Creates a configuration for `k` clusters with a fixed seed. /// /// Defaults: 30 max iterations, 5 restarts, 1e-4 convergence tolerance, @@ -112,11 +106,6 @@ impl Clustering { pub fn label(&self, entity: usize) -> u16 { self.labels[entity] } - - /// Returns a mutable reference to the cluster label for point `entity`. - fn label_mut(&mut self, entity: usize) -> &mut u16 { - &mut self.labels[entity] - } } // TODO: I wonder if we can make this allocation less diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/store/src/embedding/kernel.rs index 0090f63c4c5..7abaf1c8b5b 100644 --- a/libs/@local/graph/store/src/embedding/kernel.rs +++ b/libs/@local/graph/store/src/embedding/kernel.rs @@ -1,3 +1,8 @@ +#![expect( + clippy::inline_always, + reason = "while usually discouraged, SIMD operations need to be inlined, as otherwise we \ + spill SIMD registers, see the SIMD documentation." +)] use core::simd::{Simd, f32x8, num::SimdFloat as _}; /// Fused multiply-add when the target has native FMA, separate mul+add otherwise. @@ -241,11 +246,7 @@ pub(crate) unsafe fn normalize(value: &mut [f32]) { /// # Safety /// * all six slices have length `d` /// * `d` is a multiple of 8 -#[expect( - clippy::inline_always, - reason = "micro-kernel must inline into nearest4 to keep accumulators in registers" -)] -#[inline(always)] +#[inline(always)] // micro-kernel must inline nearest4 to keep accumulators in registers pub(crate) unsafe fn micro_4x2( p0: &[f32], p1: &[f32], diff --git a/libs/@local/graph/store/src/embedding/mod.rs b/libs/@local/graph/store/src/embedding/mod.rs index cefb998fb67..d008b247ce0 100644 --- a/libs/@local/graph/store/src/embedding/mod.rs +++ b/libs/@local/graph/store/src/embedding/mod.rs @@ -1,13 +1,11 @@ #![expect( unsafe_code, - dead_code, clippy::indexing_slicing, clippy::float_arithmetic, clippy::min_ident_chars, clippy::many_single_char_names, - reason = "embedding module is under active development; dead_code is expected until the \ - public API is wired up. Single-char idents (k, n, m, d, x) are standard \ - mathematical notation for clustering." + reason = "embedding module is under active development. Single-char idents (k, n, m, d, x) \ + are standard mathematical notation for clustering." )] pub mod clustering; From d1c214ef5b772dc7edd6155c27fa5cd9be2caa41 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:02:03 +0200 Subject: [PATCH 07/78] fix: lints --- libs/@local/graph/store/src/embedding/kernel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/store/src/embedding/kernel.rs index 7abaf1c8b5b..d5610052694 100644 --- a/libs/@local/graph/store/src/embedding/kernel.rs +++ b/libs/@local/graph/store/src/embedding/kernel.rs @@ -7,8 +7,8 @@ use core::simd::{Simd, f32x8, num::SimdFloat as _}; /// Fused multiply-add when the target has native FMA, separate mul+add otherwise. /// -/// On aarch64, FMA is part of the base NEON instruction set (`fmla`). -/// On x86_64, FMA requires the `fma` target feature (`vfmadd`); without it, +/// On `aarch64`, FMA is part of the base NEON instruction set (`fmla`). +/// On `x86_64`, FMA requires the `fma` target feature (`vfmadd`); without it, /// `StdFloat::mul_add` falls back to a per-lane `fmaf` libc call which /// destroys throughput. The non-FMA path uses a plain multiply and add /// (`vmulps` + `vaddps`) instead. From 2249f1bd9293e0725e03535d1dbddc72bba0aa12 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:06:51 +0200 Subject: [PATCH 08/78] fix: openapi schema --- libs/@local/graph/api/openapi/openapi.json | 7 ++++++- libs/@local/graph/store/src/entity/store.rs | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 1f224711a82..365193087c7 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -3804,7 +3804,12 @@ "minimum": 0 }, "dimension": { - "$ref": "#/components/schemas/NonZero" + "type": "integer", + "format": "int32", + "description": "Embedding dimension after matryoshka truncation. Must be a positive\nmultiple of 8; values above 3072 are rejected. Defaults to 256.", + "default": 256, + "example": 256, + "minimum": 1 }, "entityIds": { "type": "array", diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 91bccfdf907..e6c43cdea12 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -539,6 +539,7 @@ pub struct ClusterEntitiesParams { /// Embedding dimension after matryoshka truncation. Must be a positive /// multiple of 8; values above 3072 are rejected. Defaults to 256. #[serde(default = "ClusterEntitiesParams::default_dimension")] + #[cfg_attr(feature = "utoipa", schema(value_type = u16, minimum = 1, default = 256, example = 256))] pub dimension: NonZero, /// Seed for the random number generator used in clustering. From 9b7fef660c81c2e762656de95060d882af7ee928 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:09:22 +0200 Subject: [PATCH 09/78] fix: docs --- libs/@local/graph/store/src/embedding/clustering.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index 45839919a68..cec24ed18ab 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -9,8 +9,8 @@ use super::{dimension::Dimension, kernel}; /// Parameters for k-means clustering. /// -/// Use [`Config::for_k`] or [`Config::for_k_with_seed`] to construct with -/// reasonable defaults, then override individual fields as needed. +/// Use [`Config::for_k_with_seed`] to construct with reasonable defaults, then override individual +/// fields as needed. pub struct Config { /// Number of clusters. pub k: u16, From 121cd2293669338dc77ca9ba17a49d61a1e7abf2 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:11:31 +0200 Subject: [PATCH 10/78] fix: docs --- libs/@local/graph/store/src/embedding/kernel.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/store/src/embedding/kernel.rs index d5610052694..1b971bc23b1 100644 --- a/libs/@local/graph/store/src/embedding/kernel.rs +++ b/libs/@local/graph/store/src/embedding/kernel.rs @@ -320,9 +320,8 @@ pub(crate) unsafe fn micro_4x2( /// [`micro_4x2`] tiled kernel. /// /// Returns `(centroid_index, raw_dot_product)` for each of the 4 points. -/// The raw dot product is **not** a distance; the caller must convert via -/// [`squared_chord_distance`](super::clustering::squared_chord_distance) -/// if needed. +/// The raw dot product is **not** a distance; and must be converted via +/// using the chord distance formula. /// /// # Safety /// From 9bffac9cefeef55860e908fbe30748888d3cd87d Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:21:06 +0200 Subject: [PATCH 11/78] feat: embedding clustering review --- Cargo.lock | 2 + libs/@local/graph/api/openapi/openapi.json | 8 +- .../store/postgres/knowledge/entity/mod.rs | 6 + libs/@local/graph/store/Cargo.toml | 12 +- libs/@local/graph/store/benches/embedding.rs | 237 ++++ .../graph/store/src/embedding/clustering.rs | 1260 ++++++++++------- .../graph/store/src/embedding/kernel.rs | 279 ++-- libs/@local/graph/store/src/embedding/mod.rs | 5 +- libs/@local/graph/store/src/entity/store.rs | 5 + 9 files changed, 1145 insertions(+), 669 deletions(-) create mode 100644 libs/@local/graph/store/benches/embedding.rs diff --git a/Cargo.lock b/Cargo.lock index 375c424b10a..8c32c3395bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3803,6 +3803,8 @@ name = "hash-graph-store" version = "0.0.0" dependencies = [ "bytes", + "codspeed-criterion-compat", + "darwin-kperf-criterion", "derive-where", "derive_more", "error-stack", diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 365193087c7..a93edfd1e59 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -3832,7 +3832,8 @@ "description": "Result of [`EntityStore::cluster_entities`].", "required": [ "clusters", - "missingEmbeddings" + "missingEmbeddings", + "inertia" ], "properties": { "clusters": { @@ -3842,6 +3843,11 @@ }, "description": "One entry per non-empty cluster. Empty clusters (no points assigned)\nare omitted." }, + "inertia": { + "type": "number", + "format": "float", + "description": "Sum of squared chord distances from every clustered entity to its\nassigned centroid. Lower is tighter; comparable across runs over the\nsame entities, e.g. to choose a cluster count. `0.0` when nothing was\nclustered." + }, "missingEmbeddings": { "type": "array", "items": { diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 50311503127..4faecf6befb 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2654,6 +2654,10 @@ where // Truncate server-side via `subvector` so postgres only sends // `truncated_dim`-dimensional vectors over the wire. + // + // Matryoshka truncation shortens the vectors without re-normalizing; + // that is fine here because spherical k-means normalizes internally + // (it works with inverse norms), so no `l2_normalize` is needed. let row_stream = self .as_client() .query_raw( @@ -2722,6 +2726,7 @@ where return Ok(ClusterEntitiesResponse { clusters: Vec::new(), missing_embeddings, + inertia: 0.0, }); } @@ -2764,6 +2769,7 @@ where Ok(ClusterEntitiesResponse { clusters, missing_embeddings, + inertia: result.inertia, }) } } diff --git a/libs/@local/graph/store/Cargo.toml b/libs/@local/graph/store/Cargo.toml index c02ac60c582..afd99bc632c 100644 --- a/libs/@local/graph/store/Cargo.toml +++ b/libs/@local/graph/store/Cargo.toml @@ -40,14 +40,20 @@ tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } [dev-dependencies] -hash-codegen = { workspace = true } -insta = { workspace = true } -tokio = { workspace = true, features = ["macros"] } +codspeed-criterion-compat = { workspace = true } +darwin-kperf-criterion = { workspace = true, features = ["codspeed"] } +hash-codegen = { workspace = true } +insta = { workspace = true } +tokio = { workspace = true, features = ["macros"] } [[test]] name = "codegen" required-features = ["codegen"] +[[bench]] +name = "embedding" +harness = false + [features] codegen = ["dep:specta", "type-system/codegen", "hash-graph-authorization/codegen"] utoipa = ["hash-graph-temporal-versioning/utoipa", "type-system/utoipa", "dep:utoipa"] diff --git a/libs/@local/graph/store/benches/embedding.rs b/libs/@local/graph/store/benches/embedding.rs new file mode 100644 index 00000000000..13094b45589 --- /dev/null +++ b/libs/@local/graph/store/benches/embedding.rs @@ -0,0 +1,237 @@ +//! Benchmarks for the embedding k-means module. +//! +//! Two groups: +//! +//! * `embedding/kernel/*` — single-threaded SIMD micro-kernels, measured in retired instructions +//! via Apple PMCs (near-deterministic; requires root on macOS) with an automatic wall-clock +//! fallback on other platforms. +//! * `embedding/cluster/*` — end-to-end [`cluster`] runs. Always wall-clock, because the work is +//! spread across the rayon pool and per-thread instruction counts would only see the calling +//! thread. +//! +//! [`cluster`]: hash_graph_store::embedding::clustering::cluster +#![expect( + unsafe_code, + clippy::float_arithmetic, + clippy::indexing_slicing, + clippy::integer_division, + clippy::integer_division_remainder_used, + clippy::min_ident_chars, + clippy::significant_drop_tightening, + reason = "benchmarks exercise the unsafe SIMD kernels directly and build float test data; \ + single-char idents (k, n, d) are standard mathematical notation for clustering; the \ + drop-tightening warning originates inside `criterion_group!`" +)] + +use core::hint::black_box; + +use codspeed_criterion_compat::{ + BenchmarkId, Criterion, criterion_group, criterion_main, measurement::Measurement, +}; +use hash_graph_store::embedding::{ + clustering::{Config, cluster}, + dimension::Dimension, + kernel, +}; +use rand::{RngExt as _, SeedableRng as _}; +use rand_xoshiro::Xoshiro256PlusPlus; + +/// Uniform random values in `[-1, 1)`. +fn random_vec(len: usize, seed: u64) -> Vec { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + core::iter::repeat_with(|| rng.random_range(-1.0..1.0)) + .take(len) + .collect() +} + +/// Uniform random values in `[0.1, 1)`, guaranteed positive so repeated +/// accumulation saturates at infinity instead of producing NaNs. +fn random_positive_vec(len: usize, seed: u64) -> Vec { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + core::iter::repeat_with(|| rng.random_range(0.1..1.0)) + .take(len) + .collect() +} + +/// Well-separated blobs: `k` clusters of `points_per_cluster` points in +/// `d`-dimensional space, each with a dominant axis. Mirrors the shape of +/// real embedding workloads better than uniform noise: the fit converges +/// instead of always exhausting `max_iters`. +fn blobs(points_per_cluster: usize, k: usize, d: usize, seed: u64) -> Vec { + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); + let mut data = vec![0.0_f32; points_per_cluster * k * d]; + + for (index, row) in data.chunks_exact_mut(d).enumerate() { + let axis = (index / points_per_cluster) % d; + row[axis] = 10.0; + for value in row.iter_mut() { + *value += rng.random_range(-0.01..0.01); + } + } + + data +} + +const KERNEL_DIMS: &[usize] = &[256, 1536, 3072]; + +fn bench_dot(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("embedding/kernel/dot"); + + for &d in KERNEL_DIMS { + let lhs = random_vec(d, 1); + let rhs = random_vec(d, 2); + + group.bench_with_input(BenchmarkId::from_parameter(d), &d, |bencher, _| { + // SAFETY: both slices have length `d`, a multiple of 8. + bencher.iter(|| unsafe { kernel::dot(black_box(&lhs), black_box(&rhs)) }); + }); + } + + group.finish(); +} + +fn bench_add_scaled_into(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("embedding/kernel/add_scaled_into"); + + for &d in KERNEL_DIMS { + let src = random_positive_vec(d, 3); + let mut dst = random_positive_vec(d, 4); + + group.bench_with_input(BenchmarkId::from_parameter(d), &d, |bencher, _| { + // SAFETY: both slices have length `d`, a multiple of 8. + bencher.iter(|| unsafe { + kernel::add_scaled_into(black_box(&mut dst), black_box(&src), black_box(0.5)); + }); + }); + } + + group.finish(); +} + +fn bench_micro_4x2(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("embedding/kernel/micro_4x2"); + + for &d in KERNEL_DIMS { + let points: Vec> = (0..4).map(|seed| random_vec(d, 10 + seed)).collect(); + let c0 = random_vec(d, 20); + let c1 = random_vec(d, 21); + + group.bench_with_input(BenchmarkId::from_parameter(d), &d, |bencher, _| { + // SAFETY: all six slices have length `d`, a multiple of 8. + bencher.iter(|| unsafe { + kernel::micro_4x2( + black_box(&points[0]), + black_box(&points[1]), + black_box(&points[2]), + black_box(&points[3]), + black_box(&c0), + black_box(&c1), + ) + }); + }); + } + + group.finish(); +} + +macro_rules! nz { + ($expr:expr) => { + const { ::core::num::NonZero::new($expr).unwrap() } + }; +} + +fn bench_nearest4(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("embedding/kernel/nearest4"); + + // k = 15 exercises the odd-k remainder path. + for &(d, k) in &[ + (256, nz!(15)), + (256, nz!(16)), + (256, nz!(64)), + (1536, nz!(16)), + (3072, nz!(16)), + ] { + let points: Vec> = (0..4).map(|seed| random_vec(d, 30 + seed)).collect(); + let centroids = random_vec(k.get() * d, 40); + + group.bench_with_input( + BenchmarkId::new(format!("d{d}"), k), + &(d, k), + |bencher, _| { + // SAFETY: point slices have length `d` (multiple of 8), + // centroids has length `k * d`, and `k > 0`. + bencher.iter(|| unsafe { + kernel::nearest4( + black_box(&points[0]), + black_box(&points[1]), + black_box(&points[2]), + black_box(&points[3]), + black_box(¢roids), + black_box(k), + black_box(d), + ) + }); + }, + ); + } + + group.finish(); +} + +fn bench_cluster(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("embedding/cluster"); + group.sample_size(10); + + let dimension = Dimension::new(256).expect("256 is a positive multiple of 8"); + + // (n, k): n = 10k exercises the subsampled fit (m = 8192) plus the + // full-data refinement; n = 50k shifts the weight onto the full-data + // passes. + for &(n, k) in &[ + (10_000_usize, 8_u16), + (10_000, 32), + (10_000, 128), + (50_000, 32), + ] { + let data = blobs(n / usize::from(k), usize::from(k), 256, 7); + let config = Config::for_k_with_seed(k, 42); + + group.bench_with_input( + BenchmarkId::new(format!("n{n}_d256"), k), + &(n, k), + |bencher, _| { + bencher.iter(|| cluster(black_box(&data), black_box(dimension), &config)); + }, + ); + } + + group.finish(); +} + +fn kernel_measurement() -> Criterion { + use core::time::Duration; + + // Retired instructions on Apple Silicon (needs root there), wall-clock + // fallback everywhere else. Instruction counts are near-deterministic, + // so short windows and small samples suffice. + Criterion::default() + .with_measurement( + darwin_kperf_criterion::HardwareCounter::instructions() + .expect("instruction counting requires root on Apple Silicon (run under sudo)"), + ) + .warm_up_time(Duration::from_millis(500)) + .measurement_time(Duration::from_secs(1)) + .sample_size(20) +} + +criterion_group!( + name = kernel; + config = kernel_measurement(); + targets = bench_dot, bench_add_scaled_into, bench_micro_4x2, bench_nearest4 +); +criterion_group!( + name = clustering; + config = Criterion::default(); + targets = bench_cluster +); +criterion_main!(kernel, clustering); diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index cec24ed18ab..0ff02308ee7 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -1,9 +1,16 @@ use alloc::borrow::Cow; -use core::{cmp, mem, num::NonZero}; +use core::{cmp, num::NonZero}; +use std::collections::HashSet; use rand::{Rng, RngExt as _, SeedableRng as _}; use rand_xoshiro::Xoshiro256PlusPlus; -use rayon::prelude::*; +use rayon::{ + iter::{ + IndexedParallelIterator as _, IntoParallelIterator as _, IntoParallelRefIterator as _, + IntoParallelRefMutIterator as _, ParallelIterator as _, + }, + slice::{ParallelSlice as _, ParallelSliceMut as _}, +}; use super::{dimension::Dimension, kernel}; @@ -29,10 +36,14 @@ pub struct Config { /// Capped to avoid quadratic seeding cost on very large datasets. pub sample_cap: usize, - /// Base seed for the PRNG. Each restart derives its own seed from this. + /// Base seed for the PRNG. + /// + /// Runs with the same seed, input, and configuration produce identical + /// labels and centroids. pub seed: u64, - /// Number of points processed per batch in the assignment step. + /// Number of points processed per batch in the parallel passes. + /// Values larger than the number of points are clamped. pub chunk: NonZero, } @@ -64,10 +75,26 @@ pub struct Clustering { pub dimension: Dimension, /// Flat centroid matrix, `k * d` elements in row-major order. + /// + /// Centroids are unit-normalized, with one exception: a cluster whose + /// members are all zero-norm points keeps a zero centroid, since there + /// is no direction to normalize. pub centroids: Box<[f32]>, /// Cluster assignment for each input point, values in `0..k`. + /// + /// When [`cluster`] ran with `k == 0` (requested or clamped) the labels + /// are all-zero placeholders and there are no centroids to index. pub labels: Box<[u16]>, + + /// Sum of squared chord distances from every input point to its assigned + /// centroid, measured against the final centroids. Lower is tighter; + /// comparable across runs on the same input, e.g. for choosing `k`. + /// `0.0` when `k == 0` or the input is empty. + /// + /// The value is precise only up to floating-point summation order: + /// repeated runs over identical input can differ in the final bits. + pub inertia: f32, } impl Clustering { @@ -85,10 +112,15 @@ impl Clustering { centroids, labels, dimension: d, + inertia: 0.0, } } /// Returns the `D`-dimensional slice for centroid `cluster`. + /// + /// # Panics + /// + /// Panics if `cluster` is not below the number of centroids. #[must_use] pub fn centroid(&self, cluster: u16) -> &[f32] { &self.centroids[cluster as usize * (self.dimension.get() as usize) @@ -102,23 +134,40 @@ impl Clustering { } /// Returns the cluster label for point `entity`. + /// + /// # Panics + /// + /// Panics if `entity` is not below the number of input points. #[must_use] pub fn label(&self, entity: usize) -> u16 { self.labels[entity] } } -// TODO: I wonder if we can make this allocation less +/// Draws `m` distinct indices uniformly at random from `0..n` in O(m) time +/// and memory (Robert Floyd's sampling algorithm). +/// +/// The result is sorted and deterministic for a given RNG state. fn sample_indices(n: usize, m: usize, mut rng: impl Rng) -> Vec { - let mut idx: Vec = (0..n).collect(); + debug_assert!(m <= n); + + let mut selected: HashSet = HashSet::with_capacity(m); + + for upper in n - m..n { + let candidate = rng.random_range(0..=upper); - for i in 0..m { - let j = i + rng.random_range(0..n - i); // partial Fisher–Yates - idx.swap(i, j); + if !selected.insert(candidate) { + // `candidate` was already drawn in an earlier round. Earlier + // rounds only drew from `0..upper`, so `upper` itself is fresh. + selected.insert(upper); + } } - idx.truncate(m); - idx + let mut indices: Vec = selected.into_iter().collect(); + // Sorting erases the hash set's nondeterministic iteration order and + // turns the caller's gather into a forward walk over `x`. + indices.sort_unstable(); + indices } /// Squared chord distance between a point and a unit centroid. @@ -147,36 +196,33 @@ fn squared_chord_distance(dot: f32, point_inv_norm: f32) -> f32 { /// /// # Safety /// -/// * `point.len() == D` -/// * `centroids.len() == k * D` -/// * `k > 0` -/// * `D` is a multiple of 8 (enforced at compile time by the const generic). +/// * `point.len() == d` +/// * `centroids.len() == k * d` +/// * `d` is a multiple of 8 (guaranteed by [`Dimension`]). #[inline] #[must_use] pub(crate) unsafe fn nearest_centroid( point: &[f32], point_inv_norm: f32, centroids: &[f32], - k: usize, + k: NonZero, d: usize, ) -> (u16, f32) { debug_assert_eq!(point.len(), d); - debug_assert_eq!(centroids.len(), k * d); - debug_assert!(k > 0); + debug_assert_eq!(centroids.len(), k.get() * d); // SAFETY: the caller guarantees these preconditions. The hints let the // compiler elide bounds checks on the centroid slicing inside the loop. unsafe { core::hint::assert_unchecked(point.len() == d); - core::hint::assert_unchecked(centroids.len() == k * d); + core::hint::assert_unchecked(centroids.len() == k.get() * d); core::hint::assert_unchecked(d.is_multiple_of(8)); - core::hint::assert_unchecked(k > 0); } let mut best = 0; let mut best_dot = f32::NEG_INFINITY; - for cluster in 0..k { + for cluster in 0..k.get() { let start = cluster * d; let centroid = ¢roids[start..start + d]; @@ -186,7 +232,7 @@ pub(crate) unsafe fn nearest_centroid( #[expect( clippy::cast_possible_truncation, - reason = "k is supposed to be low, and checked as such via the config" + reason = "cluster < k, and k originates from Config::k (u16)" )] if dot > best_dot { best = cluster as u16; @@ -197,351 +243,182 @@ pub(crate) unsafe fn nearest_centroid( (best, squared_chord_distance(best_dot, point_inv_norm)) } -/// Pre-allocated scratch space for the k-means fitting loop. +/// Assigns one chunk of points during Lloyd iterations: writes each point's +/// nearest centroid into `labels` and its squared chord distance into +/// `distances`. +/// +/// # Safety +/// +/// * `points.len() == labels.len() * d` +/// * `inv_norms.len() == labels.len()` +/// * `distances.len() == labels.len()` +/// * `centroids.len() == k * d` +/// * `d` is a multiple of 8 +unsafe fn lloyd_assign( + k: NonZero, + d: usize, + centroids: &[f32], + points: &[f32], + inv_norms: &[f32], + labels: &mut [u16], + distances: &mut [f32], +) { + let count = labels.len(); + + // SAFETY: the caller guarantees the length relations; the hints let the + // compiler elide bounds checks in the tiled loop below. + unsafe { + core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(inv_norms.len() == count); + core::hint::assert_unchecked(distances.len() == count); + core::hint::assert_unchecked(d.is_multiple_of(8)); + } + + let mut i = 0; + while i + 4 <= count { + let p0 = &points[i * d..i * d + d]; + let p1 = &points[(i + 1) * d..(i + 1) * d + d]; + let p2 = &points[(i + 2) * d..(i + 2) * d + d]; + let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + + // SAFETY: each point length d, centroids length k*d, + // k > 0, d a multiple of 8 (guaranteed by Dimension). + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + + for offset in 0..4 { + labels[i + offset] = nearest[offset].0; + distances[i + offset] = + squared_chord_distance(nearest[offset].1, inv_norms[i + offset]); + } + i += 4; + } + + while i < count { + let point = &points[i * d..i * d + d]; + + // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. + let (label, distance) = unsafe { nearest_centroid(point, inv_norms[i], centroids, k, d) }; + labels[i] = label; + distances[i] = distance; + i += 1; + } +} + +/// Per-restart scratch and state for one k-means fit on the sample. /// -/// All buffers are allocated once and reused across restarts to avoid -/// per-iteration allocation overhead. -struct Fit { - k: usize, +/// Restarts run in parallel, so each owns its buffers. [`Restart::new`] +/// hands them back zeroed. +struct Restart { + k: NonZero, m: usize, d: usize, - /// Current centroids for this restart, `k * d` elements. + /// Centroids for this restart, `k * d` elements. centroids: Box<[f32]>, - /// Best centroids seen across all restarts. - best_centroids: Box<[f32]>, /// Per-cluster accumulator for centroid recomputation, `k * d` elements. sums: Box<[f32]>, - /// Per-cluster point count for centroid averaging. + /// Per-cluster point count for the empty-cluster check. counts: Box<[usize]>, /// Per-sample-point cluster assignment. labels: Box<[u16]>, - /// Per-sample-point closest centroid distance (for k-means++ seeding). - closest_distances: Box<[f32]>, + /// Per-sample-point distance scratch. + point_distances: Box<[f32]>, /// Tracks which sample points have been selected as seeds. selected: Box<[bool]>, - /// Lowest inertia across all restarts. - best_inertia: f32, } -impl Fit { - fn new(k: usize, m: usize, d: usize) -> Self { - // SAFETY: all-zero bits are valid for f32 (IEEE 754 +0.0), usize (0), u16 (0), and bool - // (false). `Box::new_zeroed_slice` allocates zeroed memory of the correct layout - // for each type, so `assume_init` is sound in every case. - let centroids = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; - // SAFETY: see above - let best_centroids = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; +impl Restart { + fn new(k: NonZero, m: usize, d: usize) -> Self { + // SAFETY: all-zero bits are valid for `f32` (IEEE 754 +0.0), `usize` (0), `u16` (0), and + // `bool` (false). `Box::new_zeroed_slice` allocates zeroed memory of the correct + // layout for each type, so `assume_init` is sound in every case. + let centroids = unsafe { Box::<[f32]>::new_zeroed_slice(k.get() * d).assume_init() }; // SAFETY: see above - let sums = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; + let sums = unsafe { Box::<[f32]>::new_zeroed_slice(k.get() * d).assume_init() }; // SAFETY: see above - let counts = unsafe { Box::<[usize]>::new_zeroed_slice(k).assume_init() }; + let counts = unsafe { Box::<[usize]>::new_zeroed_slice(k.get()).assume_init() }; // SAFETY: see above let labels = unsafe { Box::<[u16]>::new_zeroed_slice(m).assume_init() }; // SAFETY: see above - let closest_distances = unsafe { Box::<[f32]>::new_zeroed_slice(m).assume_init() }; + let point_distances = unsafe { Box::<[f32]>::new_zeroed_slice(m).assume_init() }; // SAFETY: see above let selected = unsafe { Box::<[bool]>::new_zeroed_slice(m).assume_init() }; - let best_inertia = f32::INFINITY; Self { k, m, d, centroids, - best_centroids, sums, counts, labels, - closest_distances, + point_distances, selected, - best_inertia, } } - fn reset_centroids(&mut self) { - self.centroids.fill(0.0); - } - - fn reset_sums(&mut self) { - self.sums.fill(0.0); - } - - fn reset_counts(&mut self) { - self.counts.fill(0); - } - - fn reset_selected(&mut self) { - self.selected.fill(false); - } - - /// Reinitializes empty clusters from the sample point farthest from - /// its assigned centroid. + /// Runs one restart: k-means++ seeding followed by Lloyd iterations. /// - /// For each empty cluster, scans the sample to find the point with - /// the largest squared chord distance to its current centroid, copies - /// that point as the new centroid (normalized), and updates the - /// point's label so it won't be picked again for subsequent empty - /// clusters in the same pass. - #[expect( - clippy::cast_possible_truncation, - reason = "cluster index < k, and k originates from Config::k (u16)" - )] - fn reinit_empty_clusters(&mut self, sample: &[f32], sample_inv_norms: &[f32]) -> bool { - let &mut Self { d, k, .. } = self; - - let mut reseeded = false; - - for cluster in 0..k { - if self.counts[cluster] != 0 { - continue; - } - - reseeded = true; - let mut farthest_idx = 0; - let mut farthest_dist = -1.0_f32; - - for (i, (point, &inv_norm)) in sample.chunks_exact(d).zip(sample_inv_norms).enumerate() - { - let label = usize::from(self.labels[i]); - let c_start = label * d; - - // SAFETY: point and centroid both have length `d`, - // a multiple of 8 (guaranteed by Dimension). - let dot = unsafe { kernel::dot(point, &self.centroids[c_start..c_start + d]) }; - let dist = squared_chord_distance(dot, inv_norm); - - if dist > farthest_dist { - farthest_dist = dist; - farthest_idx = i; - } - } - - let point_start = farthest_idx * d; - let centroid_start = cluster * d; - self.centroids[centroid_start..centroid_start + d] - .copy_from_slice(&sample[point_start..point_start + d]); - - // SAFETY: centroid row has length `d`, a multiple of 8. - unsafe { - kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); - } - - // Update the label so the next empty cluster picks a different - // point (this point's distance to its new centroid is ~0). - self.labels[farthest_idx] = cluster as u16; - } - - reseeded - } - - /// Runs k-means++ initialization followed by Lloyd iterations on the - /// sample, repeating for `n_init` restarts. The best centroids (lowest - /// inertia) are stored in `self.best_centroids`. + /// Returns the sample inertia of the fitted centroids. fn run( &mut self, sample: &[f32], chunk: usize, row_chunk: usize, sample_inv_norms: &[f32], - mut rng: impl Rng, - config: &Config, - ) { - for _ in 0..config.n_init.get() { - self.reset_centroids(); - self.closest_distances.fill(f32::INFINITY); - self.reset_selected(); - - self.seed_plusplus(sample, sample_inv_norms, &mut rng); - - let inertia = self.lloyd(sample, chunk, row_chunk, sample_inv_norms, config); - - if inertia < self.best_inertia { - self.best_inertia = inertia; - mem::swap(&mut self.best_centroids, &mut self.centroids); - } - } - } - - /// Runs Lloyd iterations on the sample until convergence or `max_iters`. - /// Returns the final inertia (sum of distances to assigned centroids). - fn lloyd( - &mut self, - sample: &[f32], - chunk: usize, - row_chunk: usize, - sample_inv_norms: &[f32], + seed: u64, config: &Config, ) -> f32 { - let &mut Self { d, k, .. } = self; - let mut previous_inertia = f32::INFINITY; - let mut inertia = f32::INFINITY; - - for _ in 0..config.max_iters.get() { - inertia = sample - .par_chunks(row_chunk) - .zip(sample_inv_norms.par_chunks(chunk)) - .zip(self.labels.par_chunks_mut(chunk)) - .map(|((points, inv_norms), labels)| { - let mut inertia = 0.0; - let count = labels.len(); - - // SAFETY: each parallel chunk pairs `count` labels with - // `count * d` floats of point data and `count` inv_norms. - // `d` is a multiple of 8 (guaranteed by Dimension). - unsafe { - core::hint::assert_unchecked(points.len() == count * d); - core::hint::assert_unchecked(inv_norms.len() == count); - core::hint::assert_unchecked(d.is_multiple_of(8)); - } - - let mut i = 0; - while i + 4 <= count { - let p0 = &points[i * d..i * d + d]; - let p1 = &points[(i + 1) * d..(i + 1) * d + d]; - let p2 = &points[(i + 2) * d..(i + 2) * d + d]; - let p3 = &points[(i + 3) * d..(i + 3) * d + d]; - - // SAFETY: each point length d, centroids length k*d, - // k > 0, d a multiple of 8 (guaranteed by Dimension). - let nearest = - unsafe { kernel::nearest4(p0, p1, p2, p3, &self.centroids, k, d) }; - - let inv = [ - inv_norms[i], - inv_norms[i + 1], - inv_norms[i + 2], - inv_norms[i + 3], - ]; - for m in 0..4 { - labels[i + m] = nearest[m].0; - inertia += squared_chord_distance(nearest[m].1, inv[m]); - } - i += 4; - } - - while i < count { - let point = &points[i * d..i * d + d]; - // SAFETY: point length d, centroids length k*d, k > 0, - // d mult of 8. - let (label, distance) = - unsafe { nearest_centroid(point, inv_norms[i], &self.centroids, k, d) }; - labels[i] = label; - inertia += distance; - i += 1; - } - - inertia - }) - .sum(); - - self.reset_sums(); - self.reset_counts(); - - for ((point, label), inv_norm) in sample - .chunks_exact(d) - .zip(self.labels.iter().copied()) - .zip(sample_inv_norms.iter().copied()) - { - let cluster = usize::from(label); - let start = cluster * d; - - self.counts[cluster] += 1; - - if inv_norm == 0.0 { - continue; - } - - // SAFETY: `sums[start..start + d]` and `point` both have - // length `d`, and `d` is a multiple of 8 (guaranteed by Dimension). - unsafe { - kernel::add_scaled_into(&mut self.sums[start..start + d], point, inv_norm); - } - } - - for cluster in 0..k { - if self.counts[cluster] == 0 { - continue; - } - - let start = cluster * d; - let centroid = &mut self.centroids[start..start + d]; - let sum = &self.sums[start..start + d]; - - // SAFETY: `centroid` and `sum` both have length `D`, and `D` - // is a multiple of 8 (guaranteed by Dimension). - unsafe { - #[expect( - clippy::cast_precision_loss, - reason = "cluster count is bounded by sample_cap (≤8192), well within f32 \ - precision" - )] - let inv_count = 1.0 / self.counts[cluster] as f32; - kernel::scale_into(centroid, sum, inv_count); - } - - // SAFETY: centroid rows have length `D`, and `D` is a - // multiple of 8 (guaranteed by Dimension). - unsafe { - kernel::normalize(centroid); - } - } - - let reseeded = self.reinit_empty_clusters(sample, sample_inv_norms); - - // Skip the convergence check when a cluster was just reseeded: - // the reseeded centroid hasn't had an assignment pass yet, so - // breaking now would waste the reinit. - if !reseeded && previous_inertia.is_finite() { - let relative_change = - (previous_inertia - inertia).abs() / previous_inertia.max(f32::EPSILON); - - if relative_change <= config.tol { - break; - } - } + let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); - previous_inertia = inertia; - } + // `new` zeroes everything else; the distance scratch must start at + // infinity so the first seeding pass overwrites every entry. + self.point_distances.fill(f32::INFINITY); - inertia + self.seed_plusplus(sample, sample_inv_norms, &mut rng); + self.lloyd(sample, chunk, row_chunk, sample_inv_norms, config) } /// k-means++ D² weighted seeding. Picks `k` initial centroids from the /// sample, each chosen with probability proportional to its squared /// distance from the nearest already-chosen centroid. - fn seed_plusplus(&mut self, sample: &[f32], sample_inv_norms: &[f32], mut rng: impl Rng) { + fn seed_plusplus(&mut self, sample: &[f32], sample_inv_norms: &[f32], rng: &mut impl Rng) { let &mut Self { d, k, m, .. } = self; + let mut point = rng.random_range(0..m); - let mut restart_rng = Xoshiro256PlusPlus::seed_from_u64(rng.random()); - let mut point = restart_rng.random_range(0..m); - - for cluster in 0..k { + for cluster in 0..k.get() { let centroid_start = cluster * d; let point_start = point * d; self.centroids[centroid_start..centroid_start + d] .copy_from_slice(&sample[point_start..point_start + d]); - // SAFETY: centroid rows have length `D`, and `D` is a multiple of 8 (guaranteed by - // Dimension). + // SAFETY: centroid rows have length `d`, and `d` is a multiple of 8. unsafe { kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); } self.selected[point] = true; + // The last centroid needs no D² update: those distances would + // only be used to sample a further seed. + if cluster + 1 == k.get() { + break; + } + let centroid = &self.centroids[centroid_start..centroid_start + d]; - let total: f32 = sample + // Per-element writes only, so the pass is deterministic under + // rayon; the D² total is summed sequentially below. + sample .par_chunks_exact(d) - .zip(sample_inv_norms.par_iter().copied()) - .zip(self.closest_distances.par_iter_mut()) + .zip(sample_inv_norms.par_iter()) + .zip(self.point_distances.par_iter_mut()) .enumerate() - .map(|(index, ((point, inv_norm), closest))| { + .for_each(|(index, ((point, &inv_norm), closest))| { if self.selected[index] { *closest = 0.0; - return 0.0; + return; } // SAFETY: `point` and `centroid` both have length `D`, and @@ -552,40 +429,37 @@ impl Fit { if distance < *closest { *closest = distance; } + }); - *closest - }) - .sum(); - - if cluster + 1 == k { - break; - } + let total: f32 = self.point_distances.iter().sum(); point = if total.is_finite() && total > 0.0 { - let mut target = restart_rng.random_range(0.0..total); - let mut sampled = self - .closest_distances - .iter() - .rposition(|distance| *distance > 0.0) - .unwrap_or(0); - - for (index, distance) in self.closest_distances.iter().copied().enumerate() { + let mut target = rng.random_range(0.0..total); + let mut sampled = None; + let mut last_positive = 0; + + for (index, &distance) in self.point_distances.iter().enumerate() { if distance <= 0.0 { continue; } + last_positive = index; target -= distance; if target <= 0.0 { - sampled = index; + sampled = Some(index); break; } } - sampled + // Rounding can leave `target` marginally positive after the last bucket; + // fall back to the last point with positive mass. + sampled.unwrap_or(last_positive) } else { + // Degenerate geometry: every remaining point coincides with a seed. + // Pick uniformly among the unselected points. let remaining = self.selected.iter().filter(|selected| !**selected).count(); - let mut target = restart_rng.random_range(0..remaining); + let mut target = rng.random_range(0..remaining); let mut sampled = 0; for (index, selected) in self.selected.iter().copied().enumerate() { @@ -605,156 +479,234 @@ impl Fit { }; } } -} -/// Per-thread accumulator for parallel centroid recomputation. -/// -/// Each rayon task gets its own `Accum`; they are merged via [`Accum::merge`] -/// after the parallel fold completes. -struct Accum { - /// Per-cluster sum of normalized points, `k * d` elements. - sums: Box<[f32]>, - /// Per-cluster point count. - counts: Box<[usize]>, -} + /// Runs Lloyd iterations on the sample until convergence or `max_iters`. + /// + /// Returns the final inertia (sum of distances to assigned centroids). + fn lloyd( + &mut self, + sample: &[f32], + chunk: usize, + row_chunk: usize, + sample_inv_norms: &[f32], + config: &Config, + ) -> f32 { + let &mut Self { d, k, .. } = self; + let mut previous_inertia = f32::INFINITY; + let mut inertia = f32::INFINITY; -impl Accum { - fn new(k: usize, d: usize) -> Self { - // SAFETY: all-zero bits are valid for f32 (0.0) and usize (0). `assume_init` is - // sound after `new_zeroed_slice`. - let sums = unsafe { Box::<[f32]>::new_zeroed_slice(k * d).assume_init() }; - // SAFETY: see above - let counts = unsafe { Box::<[usize]>::new_zeroed_slice(k).assume_init() }; - Self { sums, counts } + for _ in 0..config.max_iters.get() { + // Assignment: labels and per-point distances. + // Trivially deterministic under rayon, as writes are per element. + sample + .par_chunks(row_chunk) + .zip(sample_inv_norms.par_chunks(chunk)) + .zip(self.labels.par_chunks_mut(chunk)) + .zip(self.point_distances.par_chunks_mut(chunk)) + .for_each(|(((points, inv_norms), labels), distances)| { + // SAFETY: `par_chunks(row_chunk)` with `row_chunk == chunk * d` + // pairs `labels.len()` labels, distances, and inv norms with + // `labels.len() * d` floats of points. `self.centroids` has + // length `k * d`, and `d` is a multiple of 8 (guaranteed by + // Dimension). + unsafe { + lloyd_assign(k, d, &self.centroids, points, inv_norms, labels, distances); + }; + }); + + inertia = self.point_distances.iter().sum(); + + // SAFETY: `sample.len() == m * d` with `m` labels, sums is + // `k * d` with `k` counts, and `d` is a multiple of 8 + // (guaranteed by Dimension). + unsafe { + accumulate_clusters( + sample, + &self.labels, + Some(sample_inv_norms), + &mut self.sums, + &mut self.counts, + d, + ); + } + + for cluster in 0..k.get() { + if self.counts[cluster] == 0 { + continue; + } + + let start = cluster * d; + + // Normalization is scale-invariant, so the raw sum gives the same direction as the + // average. + self.centroids[start..start + d].copy_from_slice(&self.sums[start..start + d]); + + // SAFETY: centroid rows have length `d`, and `d` is a multiple of 8 + // (guaranteed by Dimension). + unsafe { + kernel::normalize(&mut self.centroids[start..start + d]); + } + } + + let reseeded = self.reinit_empty_clusters(sample); + + // Skip the convergence check when a cluster was just reseeded: + // the reseeded centroid hasn't had an assignment pass yet, so + // breaking now would waste the reinit. + if !reseeded && previous_inertia.is_finite() { + let relative_change = + (previous_inertia - inertia).abs() / previous_inertia.max(f32::EPSILON); + + if relative_change <= config.tol { + break; + } + } + + previous_inertia = inertia; + } + + inertia } - fn merge(mut self, other: &Self, k: usize, d: usize) -> Self { - for cluster in 0..k { - let start = cluster * d; + /// Reinitializes empty clusters from the sample point farthest from its + /// assigned centroid, using the distances stored by the assignment pass. + /// + /// After relocating a point its stored distance is zeroed and its label + /// updated, so subsequent empty clusters in the same pass pick different + /// points. + #[expect( + clippy::cast_possible_truncation, + reason = "cluster index < k, and k originates from Config::k (u16)" + )] + fn reinit_empty_clusters(&mut self, sample: &[f32]) -> bool { + let &mut Self { d, k, .. } = self; + let mut reseeded = false; + + for cluster in 0..k.get() { + if self.counts[cluster] != 0 { + continue; + } + + reseeded = true; - self.counts[cluster] += other.counts[cluster]; + let mut farthest_idx = 0; + let mut farthest_dist = -1.0_f32; - // SAFETY: both cluster sum rows have length `d`, and `d` is a - // multiple of 8 (guaranteed by Dimension). + for (index, &distance) in self.point_distances.iter().enumerate() { + if distance > farthest_dist { + farthest_dist = distance; + farthest_idx = index; + } + } + + let point_start = farthest_idx * d; + let centroid_start = cluster * d; + + self.centroids[centroid_start..centroid_start + d] + .copy_from_slice(&sample[point_start..point_start + d]); + + // SAFETY: centroid rows have length `d`, a multiple of 8. unsafe { - kernel::add_into( - &mut self.sums[start..start + d], - &other.sums[start..start + d], - ); + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); } + + self.labels[farthest_idx] = cluster as u16; + self.point_distances[farthest_idx] = 0.0; } - self + reseeded } } -/// Assigns all `n` points to their nearest centroid, recomputes centroids -/// from the full population, and re-assigns labels to the final centroids. +/// Recomputes per-cluster sums and counts from labeled points. +/// +/// The result is impervious to any thread schedule order. /// -/// Uses a parallel fold/reduce: each rayon task accumulates into its own -/// [`Accum`], then results are merged. The final centroids are averaged -/// and normalized in-place. +/// `inv_norms` supplies precomputed inverse norms; pass `None` to compute +/// them on the fly. +/// +/// Zero-norm points are counted but contribute nothing to the sums. /// /// # Safety /// -/// * `x.len() == n * D` for some `n` -/// * `clustering.centroids.len() == k * D` -/// * `clustering.labels.len() == n` -/// * `k > 0` -/// * `D` is a multiple of 8 (guaranteed by Dimension) -unsafe fn assign(x: &[f32], clustering: &mut Clustering, k: usize, chunk: usize, row_chunk: usize) { - let d = clustering.dimension.get() as usize; - - let full = x - .par_chunks(row_chunk) - .zip(clustering.labels.par_chunks_mut(chunk)) - .fold( - || Accum::new(k, d), - |mut accum, (points, labels)| { - // SAFETY: `cluster` established `x.len() == n * D` and - // `centroids.len() == k * D`. `par_chunks(row_chunk)` with - // `row_chunk = chunk * D` produces chunks where - // `points.len()` is a multiple of `D` and matches `labels.len() * D`. - unsafe { - assign_chunk(&clustering.centroids, k, d, points, labels, &mut accum); +/// * `points.len() == labels.len() * d` +/// * `sums.len() == counts.len() * d` +/// * `inv_norms`, when provided, has one entry per label +/// * `d` is a multiple of 8 +unsafe fn accumulate_clusters( + points: &[f32], + labels: &[u16], + inv_norms: Option<&[f32]>, + sums: &mut [f32], + counts: &mut [usize], + d: usize, +) { + // A `debug_assert` only: an `assert_unchecked` here would be sound (the + // length is a documented precondition), but to elide the + // `inv_norms[index]` bounds check the fact would have to survive into + // the rayon closure, and that check is noise next to the `d`-wide + // kernel call it precedes. + debug_assert!(inv_norms.is_none_or(|norms| norms.len() == labels.len())); + + sums.par_chunks_exact_mut(d) + .zip(counts.par_iter_mut()) + .enumerate() + .for_each(|(cluster, (sum, count))| { + sum.fill(0.0); + *count = 0; + + for (index, (point, &label)) in points.chunks_exact(d).zip(labels).enumerate() { + if usize::from(label) != cluster { + continue; } - accum - }, - ) - .reduce(|| Accum::new(k, d), |lhs, rhs| lhs.merge(&rhs, k, d)); - - for cluster in 0..k { - if full.counts[cluster] == 0 { - continue; - } + *count += 1; - let start = cluster * d; + let inv_norm = inv_norms.map_or_else( + || { + // SAFETY: `point` has length `d`, a multiple of 8 + // (guaranteed by the caller). + let norm = unsafe { kernel::dot(point, point) }.sqrt(); - #[expect( - clippy::cast_possible_truncation, - reason = "cluster < k and k originates from Config::k (u16)" - )] - let centroid = clustering.centroid_mut(cluster as u16); - let sum = &full.sums[start..start + d]; + if norm > 0.0 { norm.recip() } else { 0.0 } + }, + |inv_norms| inv_norms[index], + ); - // SAFETY: centroid and sum both length D, a multiple of 8. - unsafe { - #[expect( - clippy::cast_precision_loss, - reason = "cluster count bounded by n; precision loss acceptable for averaging" - )] - let inv_count = 1.0 / full.counts[cluster] as f32; - kernel::scale_into(centroid, sum, inv_count); - } - // SAFETY: centroid length D, a multiple of 8. - unsafe { - kernel::normalize(centroid); - } - } + if inv_norm == 0.0 { + continue; + } - // SAFETY: centroids were just recomputed; same invariants hold. - unsafe { - reassign( - x, - &clustering.centroids, - &mut clustering.labels, - k, - d, - chunk, - row_chunk, - ); - } + // SAFETY: `sum` and `point` both have length `d`, and `d` is + // a multiple of 8 (guaranteed by the caller). + unsafe { + kernel::add_scaled_into(sum, point, inv_norm); + } + } + }); } -/// Processes one parallel chunk of the assignment step: finds the nearest -/// centroid for each point, accumulates normalized points into cluster sums, -/// and records labels. +/// Labels one parallel chunk: each point gets its nearest centroid. /// /// # Safety /// /// * `points.len() == labels.len() * d` /// * `centroids.len() >= k * d` /// * `d` is a multiple of 8 -/// * `k > 0` -/// * `accum.sums.len() >= k * d` and `accum.counts.len() >= k` -unsafe fn assign_chunk( +unsafe fn label_chunk( centroids: &[f32], - k: usize, + k: NonZero, d: usize, points: &[f32], labels: &mut [u16], - accum: &mut Accum, ) { - // field path -> disjoint capture of `centroids` only, leaving - // `labels` free for the mutable parallel borrow. let count = labels.len(); - // SAFETY: each parallel chunk pairs `count` labels with - // `count * D` floats of point data. `D` is a compile-time - // multiple of 8. + // SAFETY: each parallel chunk pairs `count` labels with `count * d` + // floats of point data; `d` is a multiple of 8 (guaranteed by Dimension). unsafe { core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(centroids.len() >= k.get() * d); core::hint::assert_unchecked(d.is_multiple_of(8)); } @@ -765,83 +717,51 @@ unsafe fn assign_chunk( let p2 = &points[(i + 2) * d..(i + 2) * d + d]; let p3 = &points[(i + 3) * d..(i + 3) * d + d]; - // SAFETY: each point length D, centroids length k*D, k > 0, D a multiple of 8 (guaranteed - // by Dimension). + // SAFETY: each point length d, centroids length k*d, k > 0, + // d a multiple of 8 (guaranteed by Dimension). let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; - let ps = [p0, p1, p2, p3]; - - for m in 0..4 { - let label = nearest[m].0; - labels[i + m] = label; - let cluster = usize::from(label); - accum.counts[cluster] += 1; - let start = cluster * d; - - // SAFETY: point length D, a multiple of 8. - let norm = unsafe { kernel::dot(ps[m], ps[m]).sqrt() }; - if norm == 0.0 { - continue; - } - - // SAFETY: `sums[start..start + D]` and `point` both have length `D`, and `D` is a - // multiple of 8 (guaranteed by Dimension). - unsafe { - kernel::add_scaled_into(&mut accum.sums[start..start + d], ps[m], norm.recip()); - } - } + labels[i] = nearest[0].0; + labels[i + 1] = nearest[1].0; + labels[i + 2] = nearest[2].0; + labels[i + 3] = nearest[3].0; i += 4; } while i < count { let point = &points[i * d..i * d + d]; - // SAFETY: point length D, centroids length k*D, k > 0, D mult of 8. + // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k, d) }; labels[i] = label; - let cluster = usize::from(label); - accum.counts[cluster] += 1; - - let start = cluster * d; - - // SAFETY: point length D. - let norm = unsafe { kernel::dot(point, point).sqrt() }; - if norm != 0.0 { - // SAFETY: `sums[start..start + D]` and `point` both - // have length `D`, and `D` is a multiple of 8. - unsafe { - kernel::add_scaled_into(&mut accum.sums[start..start + d], point, norm.recip()); - } - } i += 1; } } -/// Processes one parallel chunk of the reassignment step: updates each -/// label to the nearest final centroid. +/// Labels one parallel chunk against the final centroids and returns its +/// inertia contribution. Inverse norms are computed on the fly. /// /// # Safety /// /// * `points.len() == labels.len() * d` /// * `centroids.len() >= k * d` /// * `d` is a multiple of 8 -/// * `k > 0` -unsafe fn reassign_chunk( - k: usize, - d: usize, +unsafe fn score_chunk( centroids: &[f32], + k: NonZero, + d: usize, points: &[f32], labels: &mut [u16], -) { - let count = labels.len(); +) -> f32 { + debug_assert_eq!(points.len(), labels.len() * d); - // SAFETY: each parallel chunk pairs `count` labels with - // `count * D` floats of point data. `D` is a compile-time - // multiple of 8. + // SAFETY: The caller must ensure `points.len() == labels.len() * d`. unsafe { - core::hint::assert_unchecked(points.len() == count * d); - core::hint::assert_unchecked(d.is_multiple_of(8)); + core::hint::assert_unchecked(points.len() == labels.len() * d); } + let count = labels.len(); + let mut inertia = 0.0_f32; + let mut i = 0; while i + 4 <= count { let p0 = &points[i * d..i * d + d]; @@ -849,39 +769,52 @@ unsafe fn reassign_chunk( let p2 = &points[(i + 2) * d..(i + 2) * d + d]; let p3 = &points[(i + 3) * d..(i + 3) * d + d]; - // SAFETY: each point length D, centroids length k*D, k > 0, - // D a multiple of 8 (guaranteed by Dimension). + // SAFETY: each point length d, centroids length k*d, k > 0, + // d a multiple of 8 (guaranteed by Dimension). let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + let ps = [p0, p1, p2, p3]; - labels[i] = nearest[0].0; - labels[i + 1] = nearest[1].0; - labels[i + 2] = nearest[2].0; - labels[i + 3] = nearest[3].0; + for offset in 0..4 { + labels[i + offset] = nearest[offset].0; + + // SAFETY: point length d, a multiple of 8. + let norm = unsafe { kernel::dot(ps[offset], ps[offset]) }.sqrt(); + let inv_norm = if norm > 0.0 { norm.recip() } else { 0.0 }; + inertia += squared_chord_distance(nearest[offset].1, inv_norm); + } i += 4; } while i < count { let point = &points[i * d..i * d + d]; - // SAFETY: point length D, centroids length k*D, k > 0, D mult of 8. - let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k, d) }; + + // SAFETY: point length d, a multiple of 8. + let norm = unsafe { kernel::dot(point, point) }.sqrt(); + let inv_norm = if norm > 0.0 { norm.recip() } else { 0.0 }; + + // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. + let (label, distance) = unsafe { nearest_centroid(point, inv_norm, centroids, k, d) }; labels[i] = label; + inertia += distance; i += 1; } + + inertia } -/// Re-assigns labels to the nearest final centroid. -/// -/// After centroid recomputation, some boundary points may no longer be -/// nearest to the centroid stored under their label. This pass fixes that. +/// Labels every point with its nearest centroid. /// /// # Safety /// -/// Same as [`assign`]. +/// * `x.len() == n * d` for some `n` +/// * `clustering.centroids.len() == k * d` +/// * `clustering.labels.len() == n` +/// * `d` is a multiple of 8 unsafe fn reassign( x: &[f32], centroids: &[f32], labels: &mut [u16], - k: usize, + k: NonZero, d: usize, chunk: usize, row_chunk: usize, @@ -889,19 +822,149 @@ unsafe fn reassign( x.par_chunks(row_chunk) .zip(labels.par_chunks_mut(chunk)) .for_each(|(points, labels)| { - // SAFETY: `par_chunks(row_chunk)` with `row_chunk = chunk * D` - // ensures `points.len() == labels.len() * D`. Centroids and k + // SAFETY: `par_chunks(row_chunk)` with `row_chunk = chunk * d` + // ensures `points.len() == labels.len() * d`. Centroids and k // are valid from the caller. unsafe { - reassign_chunk(k, d, centroids, points, labels); + label_chunk(centroids, k, d, points, labels); } }); } +/// Labels every point with its nearest centroid and returns the total +/// inertia. +/// +/// Labels are exact; the inertia is precise only up to floating-point +/// summation order. +/// +/// # Safety +/// +/// * `x.len() == n * d` for some `n` +/// * `clustering.centroids.len() == k * d` +/// * `clustering.labels.len() == n` +/// * `d` is a multiple of 8 +unsafe fn reassign_scored( + x: &[f32], + centroids: &[f32], + labels: &mut [u16], + k: NonZero, + d: usize, + chunk: usize, + row_chunk: usize, +) -> f32 { + // Unordered parallel reduction: the grouping follows rayon's scheduling, so the sum is not + // bit-stable. An ordered reduction would need to collect per-chunk partials, costing an + // allocation per call. + x.par_chunks(row_chunk) + .zip(labels.par_chunks_mut(chunk)) + .map(|(points, labels)| { + // SAFETY: `par_chunks(row_chunk)` with `row_chunk = chunk * d` + // ensures `points.len() == labels.len() * d`. Centroids and k + // are valid from the caller. + unsafe { score_chunk(centroids, k, d, points, labels) } + }) + .sum() +} + +/// Assigns all `n` points to their nearest centroid, recomputes centroids +/// from the full population, and re-labels against the final centroids. +/// Returns the full-data inertia. +/// +/// `sums` and `counts` are accumulator scratch; their contents on entry are +/// irrelevant. +/// +/// # Safety +/// +/// * `x.len() == n * d` for some `n` +/// * `clustering.centroids.len() == k * d` +/// * `clustering.labels.len() == n` +/// * `sums.len() == k * d` and `counts.len() == k` +/// * `d` is a multiple of 8 +unsafe fn assign( + x: &[f32], + clustering: &mut Clustering, + k: NonZero, + chunk: usize, + row_chunk: usize, + sums: &mut [f32], + counts: &mut [usize], +) -> f32 { + let d = clustering.dimension.get() as usize; + + // 1. Label all points against the sample-fitted centroids. + // SAFETY: forwarded from the caller. + unsafe { + reassign( + x, + &clustering.centroids, + &mut clustering.labels, + k, + d, + chunk, + row_chunk, + ); + } + + // 2. Recompute centroids from the full population. + // SAFETY: `x.len() == n * d` with `n` labels, sums is `k * d` with `k` + // counts, and `d` is a multiple of 8 (guaranteed by Dimension). + unsafe { + accumulate_clusters(x, &clustering.labels, None, sums, counts, d); + } + + for (cluster, count) in counts.iter_mut().enumerate() { + if *count == 0 { + continue; + } + + let start = cluster * d; + + #[expect( + clippy::cast_possible_truncation, + reason = "cluster < k and k originates from Config::k (u16)" + )] + let centroid = clustering.centroid_mut(cluster as u16); + // Normalization is scale-invariant, so the raw sum gives the same + // direction as the average. + centroid.copy_from_slice(&sums[start..start + d]); + + // SAFETY: centroid length d, a multiple of 8. + unsafe { + kernel::normalize(centroid); + } + } + + // 3. Final labels and inertia against the recomputed centroids. + // SAFETY: centroids were just recomputed in place; same invariants hold. + unsafe { + reassign_scored( + x, + &clustering.centroids, + &mut clustering.labels, + k, + d, + chunk, + row_chunk, + ) + } +} + /// Runs spherical k-means over a flat row-major embedding matrix. /// /// `x` contains `n` points of `dimension` floats each, laid out -/// contiguously. Returns cluster assignments and unit-normalized centroids. +/// contiguously. Returns cluster assignments, unit-normalized centroids, and +/// the full-data inertia. +/// +/// Given the same input and configuration, the returned labels and +/// centroids are identical across runs; the inertia is precise only up to +/// floating-point summation order (see [`Clustering::inertia`]). +/// +/// Zero-norm points do not influence centroids, and are always assigned to +/// cluster 0 at distance 0. If a cluster consists solely of zero-norm points, +/// its centroid is zero; see [`Clustering::centroids`]. +/// +/// If `config.k == 0` or `x` is empty there is nothing to fit: the result +/// has no centroids, all-zero placeholder labels, and an inertia of `0.0`. /// /// # Panics /// @@ -917,21 +980,21 @@ pub fn cluster(x: &[f32], dimension: Dimension, config: &Config) -> Clustering { let mut clustering = Clustering::new(k, n, dimension); - if k == 0 { + let Some(k) = NonZero::new(k) else { return clustering; - } + }; - let k = usize::from(k); + let k = NonZero::from(k); let mut rng = Xoshiro256PlusPlus::seed_from_u64(config.seed); // 1. subsample (fit on all of n only when n is already small) - let m = config.sample_cap.max(k).min(n); + let m = config.sample_cap.max(k.get()).min(n); let sample = if m == n { Cow::Borrowed(x) } else { let indices = sample_indices(n, m, &mut rng); - let mut sampled = vec![0_f32; m * d]; + let mut sampled = vec![0.0_f32; m * d]; let chunks = sampled.chunks_mut(d); assert_eq!(chunks.len(), indices.len()); @@ -944,34 +1007,71 @@ pub fn cluster(x: &[f32], dimension: Dimension, config: &Config) -> Clustering { }; let sample = sample.as_ref(); - let chunk = config.chunk.get(); - let row_chunk = chunk - .checked_mul(d) - .unwrap_or_else(|| usize::MAX - (usize::MAX % d)) - .max(d); + + // Clamping to `n` keeps `row_chunk` from overflowing: `chunk * d` is at most `n * d == + // x.len()`. + let chunk = cmp::min(config.chunk.get(), n); + let row_chunk = chunk * d; let sample_inv_norms: Vec = sample .par_chunks_exact(d) .map(|point| { // SAFETY: every point is a `d`-sized row, and `d` is a multiple of 8 (guaranteed by // Dimension). - let norm = unsafe { kernel::dot(point, point).sqrt() }; + let norm = unsafe { kernel::dot(point, point) }.sqrt(); if norm > 0.0 { norm.recip() } else { 0.0 } }) .collect(); - // 2. fit on the sample, best of n_init restarts (guards against bad initializations) - let mut fit = Fit::new(k, m, d); - fit.run(sample, chunk, row_chunk, &sample_inv_norms, rng, config); - mem::swap(&mut clustering.centroids, &mut fit.best_centroids); + // 2. fit on the sample: independent k-means++ restarts in parallel, the + // run with the lowest inertia wins (guards against bad initializations). + // Seeds are pre-derived so the stream matches a sequential run; ties + // break on the restart index, which keeps the winner deterministic no + // matter how rayon schedules the restarts. + let seeds: Vec = core::iter::repeat_with(|| rng.random()) + .take(usize::try_from(config.n_init.get()).unwrap_or(usize::MAX)) + .collect(); + + let best = seeds + .into_par_iter() + .enumerate() + .map(|(index, seed)| { + let mut restart = Restart::new(k, m, d); + let inertia = restart.run(sample, chunk, row_chunk, &sample_inv_norms, seed, config); + + (inertia, index, restart) + }) + .min_by(|lhs, rhs| lhs.0.total_cmp(&rhs.0).then(lhs.1.cmp(&rhs.1))) + .expect("config.n_init is non-zero, so at least one restart ran"); + + // Reuse the winning restart's buffers: its centroids become the result + // and its per-cluster accumulators serve the full-data recomputation, + // instead of allocating fresh ones. + let Restart { + centroids, + mut sums, + mut counts, + .. + } = best.2; + + clustering.centroids = centroids; // 3. assign points to clusters // SAFETY: `x.len() == n * d` (asserted above), `clustering.centroids.len() == k * d`, - // `k > 0` (checked above), `d` is a multiple of 8 (guaranteed by Dimension). - unsafe { - assign(x, &mut clustering, k, chunk, row_chunk); - } + // `sums` and `counts` are the restart's `k * d` and `k` sized accumulators, + // and `d` is a multiple of 8 (guaranteed by Dimension). + clustering.inertia = unsafe { + assign( + x, + &mut clustering, + k, + chunk, + row_chunk, + &mut sums, + &mut counts, + ) + }; clustering } @@ -986,6 +1086,12 @@ mod tests { )] use super::*; + macro_rules! nz { + ($expr:expr) => { + const { NonZero::new($expr).unwrap() } + }; + } + /// Builds well-separated blob clusters in D-dimensional space. /// /// Each blob has a dominant axis so clusters are far apart in cosine @@ -1029,9 +1135,9 @@ mod tests { } /// Random unit-norm centroids in `D`-dimensional space. - fn unit_random(k: usize, seed: u64) -> Vec { + fn unit_random(k: NonZero, seed: u64) -> Vec { let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); - let mut c = vec![0.0_f32; k * D]; + let mut c = vec![0.0_f32; k.get() * D]; for row in c.chunks_exact_mut(D) { for v in row.iter_mut() { *v = rng.random_range(-1.0..1.0); @@ -1046,11 +1152,12 @@ mod tests { /// Brute-force nearest centroid by cosine similarity. #[expect(clippy::cast_possible_truncation, reason = "k is small in tests")] - fn brute_nearest_cosine(point: &[f32], centroids: &[f32], k: usize) -> u16 { + fn brute_nearest_cosine(point: &[f32], centroids: &[f32], k: NonZero) -> u16 { let pn = l2(point); let mut best = 0_u16; let mut best_cos = f32::NEG_INFINITY; - for c in 0..k { + + for c in 0..k.get() { let cent = ¢roids[c * D..(c + 1) * D]; let d: f32 = point.iter().zip(cent).map(|(a, b)| a * b).sum(); let cn = l2(cent); @@ -1059,6 +1166,7 @@ mod tests { } else { d / (pn * cn) }; + if cos > best_cos { best_cos = cos; best = c as u16; @@ -1131,12 +1239,26 @@ mod tests { } } + #[test] + fn sample_indices_unique_sorted_in_range() { + let rng = Xoshiro256PlusPlus::seed_from_u64(1); + let indices = sample_indices(1000, 100, rng); + + assert_eq!(indices.len(), 100); + assert!( + indices.is_sorted_by(|lhs, rhs| lhs < rhs), + "indices must be strictly increasing (sorted, unique)" + ); + assert!(indices.iter().all(|&index| index < 1000)); + } + #[test] fn cluster_empty_input() { let config = Config::for_k_with_seed(4, 42); let result = cluster(&[], dim(8), &config); assert_eq!(result.labels.len(), 0); assert_eq!(result.centroids.len(), 0); + assert_eq!(result.inertia, 0.0); } #[test] @@ -1146,6 +1268,8 @@ mod tests { let result = cluster(&data, dim(8), &config); assert_eq!(result.labels.len(), 1); assert_eq!(result.labels[0], 0); + assert_eq!(result.centroids.len(), 0); + assert_eq!(result.inertia, 0.0); } #[test] @@ -1229,21 +1353,31 @@ mod tests { assert_eq!(r1.labels, r2.labels); assert_eq!(r1.centroids, r2.centroids); + + // The inertia reduction is a parallel sum, so it is only + // deterministic up to float summation order. + let tolerance = r1.inertia.abs().max(f32::EPSILON) * 1e-5; + assert!( + (r1.inertia - r2.inertia).abs() <= tolerance, + "inertia should agree within summation-order tolerance: {} vs {}", + r1.inertia, + r2.inertia + ); } #[test] - fn cluster_different_seeds_may_differ() { - let (data, _) = make_blobs::<8>(30, 3, 555); + fn cluster_recovers_blobs_across_seeds() { + let (data, truth) = make_blobs::<8>(30, 3, 555); - let r1 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 42)); - let r2 = cluster(&data, dim(8), &Config::for_k_with_seed(3, 9999)); - - // Not guaranteed to differ, but with well-separated blobs and - // different seeds the label permutation usually differs. - assert!( - r1.labels != r2.labels, - "different seeds produced identical label vectors (possible but unlikely)" - ); + for seed in [42, 9999] { + let result = cluster(&data, dim(8), &Config::for_k_with_seed(3, seed)); + let acc = accuracy(&result.labels, &truth, 3); + assert!( + acc > 0.95, + "seed {seed}: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } } #[test] @@ -1314,6 +1448,72 @@ mod tests { ); } + #[test] + fn cluster_d256_recovers_blobs() { + // Production default dimension (matryoshka truncation target). + let (data, truth) = make_blobs::<256>(50, 4, 1234); + let config = Config::for_k_with_seed(4, 42); + let result = cluster(&data, dim(256), &config); + + let acc = accuracy(&result.labels, &truth, 4); + assert!( + acc > 0.95, + "D=256: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_d1536_recovers_blobs() { + let (data, truth) = make_blobs::<1536>(20, 3, 4321); + let config = Config::for_k_with_seed(3, 42); + let result = cluster(&data, dim(1536), &config); + + let acc = accuracy(&result.labels, &truth, 3); + assert!( + acc > 0.95, + "D=1536: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + + #[test] + fn cluster_chunk_sizes_produce_valid_results() { + let (data, truth) = make_blobs::<8>(50, 4, 314); + + for chunk in [1_usize, 3, 1_000_000] { + let mut config = Config::for_k_with_seed(4, 42); + config.chunk = NonZero::new(chunk).expect("chunk is non-zero"); + let result = cluster(&data, dim(8), &config); + + let acc = accuracy(&result.labels, &truth, 4); + assert!( + acc > 0.95, + "chunk={chunk}: expected >95% accuracy, got {:.1}%", + acc * 100.0 + ); + } + } + + #[test] + fn cluster_inertia_reflects_fit_quality() { + let (data, _) = make_blobs::<8>(50, 4, 99); + + let tight = cluster(&data, dim(8), &Config::for_k_with_seed(4, 42)); + assert!(tight.inertia.is_finite()); + assert!(tight.inertia >= 0.0); + + // Forcing 4 well-separated blobs into a single cluster must fit + // strictly worse. + let loose = cluster(&data, dim(8), &Config::for_k_with_seed(1, 42)); + assert!( + loose.inertia > tight.inertia, + "k=1 inertia {} should exceed k=4 inertia {}", + loose.inertia, + tight.inertia + ); + } + #[test] fn cluster_recovers_with_subsampling() { // n=12000 with sample_cap=1024 exercises the Cow::Owned path. @@ -1361,7 +1561,7 @@ mod tests { #[test] fn nearest_centroid_matches_brute_force_cosine() { - let k = 7; + let k = nz!(7); let centroids = unit_random(k, 99); let mut rng = Xoshiro256PlusPlus::seed_from_u64(100); @@ -1385,7 +1585,7 @@ mod tests { #[test] fn nearest_centroid_argmax_independent_of_inv_norm() { - let k = 5; + let k = nz!(5); let centroids = unit_random(k, 7); let mut rng = Xoshiro256PlusPlus::seed_from_u64(8); diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/store/src/embedding/kernel.rs index 1b971bc23b1..67f99a66831 100644 --- a/libs/@local/graph/store/src/embedding/kernel.rs +++ b/libs/@local/graph/store/src/embedding/kernel.rs @@ -3,7 +3,10 @@ reason = "while usually discouraged, SIMD operations need to be inlined, as otherwise we \ spill SIMD registers, see the SIMD documentation." )] -use core::simd::{Simd, f32x8, num::SimdFloat as _}; +use core::{ + num::NonZero, + simd::{Simd, f32x8, num::SimdFloat as _}, +}; /// Fused multiply-add when the target has native FMA, separate mul+add otherwise. /// @@ -39,7 +42,7 @@ fn simd_mul_add(lhs: f32x8, rhs: f32x8, acc: f32x8) -> f32x8 { /// * Both lengths are multiples of 8. #[inline] #[must_use] -pub(crate) unsafe fn dot(lhs: &[f32], rhs: &[f32]) -> f32 { +pub unsafe fn dot(lhs: &[f32], rhs: &[f32]) -> f32 { debug_assert!(lhs.len().is_multiple_of(8) && lhs.len() == rhs.len()); // SAFETY: the caller guarantees equal lengths and a multiple of 8. @@ -96,63 +99,6 @@ pub(crate) unsafe fn dot(lhs: &[f32], rhs: &[f32]) -> f32 { (s0 + s1 + s2 + s3).reduce_sum() } -/// Adds `src` element-wise into `dst`. -/// -/// # Safety -/// -/// * `dst.len() == src.len()` -/// * Both lengths are multiples of 8. -#[inline] -pub(crate) unsafe fn add_into(dst: &mut [f32], src: &[f32]) { - debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); - - // SAFETY: the caller guarantees equal lengths and a multiple of 8. - unsafe { - core::hint::assert_unchecked(dst.len() == src.len()); - core::hint::assert_unchecked(dst.len().is_multiple_of(8)); - } - - let (dst, _) = dst.as_chunks_mut::<8>(); - let (src, _) = src.as_chunks::<8>(); - - // SAFETY: same reasoning as the pre-chunk hints: equal input lengths - // that are multiples of 8 produce equal chunk counts. - unsafe { core::hint::assert_unchecked(dst.len() == src.len()) } - - for index in 0..dst.len() { - dst[index] = (f32x8::from_slice(&dst[index]) + f32x8::from_slice(&src[index])).to_array(); - } -} - -/// Writes `src * factor` element-wise into `dst`. -/// -/// # Safety -/// -/// * `dst.len() == src.len()` -/// * Both lengths are multiples of 8. -#[inline] -pub(crate) unsafe fn scale_into(dst: &mut [f32], src: &[f32], factor: f32) { - debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); - - // SAFETY: the caller guarantees equal lengths and a multiple of 8. - unsafe { - core::hint::assert_unchecked(dst.len() == src.len()); - core::hint::assert_unchecked(dst.len().is_multiple_of(8)); - } - - let factor = f32x8::splat(factor); - let (dst, _) = dst.as_chunks_mut::<8>(); - let (src, _) = src.as_chunks::<8>(); - - // SAFETY: same reasoning as the pre-chunk hints: equal input lengths - // that are multiples of 8 produce equal chunk counts. - unsafe { core::hint::assert_unchecked(dst.len() == src.len()) } - - for index in 0..dst.len() { - dst[index] = (f32x8::from_slice(&src[index]) * factor).to_array(); - } -} - /// Scales `value` in-place by `factor`. /// /// # Safety @@ -185,7 +131,7 @@ pub(crate) unsafe fn scale(value: &mut [f32], factor: f32) { /// * `dst.len() == src.len()` /// * Both lengths are multiples of 8. #[inline] -pub(crate) unsafe fn add_scaled_into(dst: &mut [f32], src: &[f32], factor: f32) { +pub unsafe fn add_scaled_into(dst: &mut [f32], src: &[f32], factor: f32) { debug_assert!(dst.len().is_multiple_of(8) && dst.len() == src.len()); // SAFETY: the caller guarantees equal lengths and a multiple of 8. @@ -247,7 +193,8 @@ pub(crate) unsafe fn normalize(value: &mut [f32]) { /// * all six slices have length `d` /// * `d` is a multiple of 8 #[inline(always)] // micro-kernel must inline nearest4 to keep accumulators in registers -pub(crate) unsafe fn micro_4x2( +#[must_use] +pub unsafe fn micro_4x2( p0: &[f32], p1: &[f32], p2: &[f32], @@ -316,6 +263,67 @@ pub(crate) unsafe fn micro_4x2( ] } +/// 4 points x 1 centroid: the odd-`k` remainder of [`nearest4`]. Four +/// independent accumulators (one per point) share each centroid load, so the +/// centroid streams through registers once instead of once per point. +/// +/// # Safety +/// +/// * all five slices have length `d` +/// * `d` is a multiple of 8 +#[inline(always)] // micro-kernel must inline into nearest4 to keep accumulators in registers +pub(crate) unsafe fn micro_4x1( + p0: &[f32], + p1: &[f32], + p2: &[f32], + p3: &[f32], + c: &[f32], +) -> [f32; 4] { + debug_assert!(p0.len().is_multiple_of(8)); + debug_assert!( + [p1.len(), p2.len(), p3.len(), c.len()] + .iter() + .all(|&l| l == p0.len()) + ); + + let (p0, _) = p0.as_chunks::<8>(); + let (p1, _) = p1.as_chunks::<8>(); + let (p2, _) = p2.as_chunks::<8>(); + let (p3, _) = p3.as_chunks::<8>(); + let (c, _) = c.as_chunks::<8>(); + + // SAFETY: the caller guarantees all five slices have equal length `d`, + // and `d` is a multiple of 8. The hints let the compiler prove that + // `as_chunks` produces equal-length chunk slices. + unsafe { + core::hint::assert_unchecked(p0.len() == p1.len()); + core::hint::assert_unchecked(p0.len() == p2.len()); + core::hint::assert_unchecked(p0.len() == p3.len()); + core::hint::assert_unchecked(p0.len() == c.len()); + } + + let mut a0 = f32x8::splat(0.0); + let mut a1 = f32x8::splat(0.0); + let mut a2 = f32x8::splat(0.0); + let mut a3 = f32x8::splat(0.0); + + for t in 0..c.len() { + let v = Simd::from_array(c[t]); + + a0 = simd_mul_add(Simd::from_array(p0[t]), v, a0); + a1 = simd_mul_add(Simd::from_array(p1[t]), v, a1); + a2 = simd_mul_add(Simd::from_array(p2[t]), v, a2); + a3 = simd_mul_add(Simd::from_array(p3[t]), v, a3); + } + + [ + a0.reduce_sum(), + a1.reduce_sum(), + a2.reduce_sum(), + a3.reduce_sum(), + ] +} + /// Finds the nearest centroid for 4 points simultaneously using the /// [`micro_4x2`] tiled kernel. /// @@ -328,16 +336,15 @@ pub(crate) unsafe fn micro_4x2( /// * All four point slices have length `d`. /// * `centroids.len() >= k * d`. /// * `d` is a multiple of 8. -/// * `k > 0`. #[inline] #[must_use] -pub(crate) unsafe fn nearest4( +pub unsafe fn nearest4( p0: &[f32], p1: &[f32], p2: &[f32], p3: &[f32], centroids: &[f32], - k: usize, + k: NonZero, d: usize, ) -> [(u16, f32); 4] { let mut best_dot = [f32::NEG_INFINITY; 4]; @@ -349,13 +356,12 @@ pub(crate) unsafe fn nearest4( core::hint::assert_unchecked(p0.len() == p1.len()); core::hint::assert_unchecked(p0.len() == p2.len()); core::hint::assert_unchecked(p0.len() == p3.len()); - core::hint::assert_unchecked(centroids.len() >= k * d); + core::hint::assert_unchecked(centroids.len() >= k.get() * d); core::hint::assert_unchecked(d.is_multiple_of(8)); - core::hint::assert_unchecked(k > 0); } let mut j = 0; - while j + 2 <= k { + while j + 2 <= k.get() { // SAFETY: `j + 2 <= k` and `centroids.len() >= k * d`, so both // slices `[j*d .. (j+2)*d]` are in-bounds. let c0 = unsafe { centroids.get_unchecked(j * d..j * d + d) }; @@ -382,19 +388,19 @@ pub(crate) unsafe fn nearest4( j += 2; } - // Handle odd k: one remaining centroid. - if j < k { + // Handle odd k: one remaining centroid via the 4x1 tile. + if j < k.get() { let c = ¢roids[j * d..j * d + d]; - let ps = [p0, p1, p2, p3]; + // SAFETY: all five slices have length `d`, a multiple of 8. + let dots = unsafe { micro_4x1(p0, p1, p2, p3, c) }; + + #[expect( + clippy::cast_possible_truncation, + reason = "k originates from Config::k (u16)" + )] for m in 0..4 { - // SAFETY: point and centroid both have length `d`, a multiple of 8. - let d = unsafe { dot(ps[m], c) }; - #[expect( - clippy::cast_possible_truncation, - reason = "k originates from Config::k (u16)" - )] - if d > best_dot[m] { - best_dot[m] = d; + if dots[m] > best_dot[m] { + best_dot[m] = dots[m]; best_idx[m] = j as u16; } } @@ -414,6 +420,12 @@ mod tests { use super::*; + macro_rules! nz { + ($expr:expr) => { + const { NonZero::new($expr).unwrap() } + }; + } + /// Scalar dot product for reference. fn ref_dot(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b).map(|(x, y)| x * y).sum() @@ -510,55 +522,6 @@ mod tests { assert_eq!(got, 0.0); } - #[test] - fn add_into_matches_scalar() { - let src = ramp(16, 1.0); - let mut dst = ramp(16, 0.5); - let expected: Vec = dst.iter().zip(&src).map(|(d, s)| d + s).collect(); - // SAFETY: both slices have length 16, a multiple of 8. - unsafe { add_into(&mut dst, &src) } - assert_eq!(dst, expected); - } - - #[test] - fn add_into_zero_is_identity() { - let zeros = vec![0.0_f32; 24]; - let mut dst = ramp(24, 1.0); - let original = dst.clone(); - // SAFETY: both slices have length 24, a multiple of 8. - unsafe { add_into(&mut dst, &zeros) } - assert_eq!(dst, original); - } - - #[test] - fn scale_into_matches_scalar() { - let src = ramp(16, 1.0); - let mut dst = vec![0.0_f32; 16]; - let factor = 2.5; - let expected: Vec = src.iter().map(|x| x * factor).collect(); - // SAFETY: both slices have length 16, a multiple of 8. - unsafe { scale_into(&mut dst, &src, factor) } - assert_eq!(dst, expected); - } - - #[test] - fn scale_into_zero_gives_zeros() { - let src = ramp(8, 1.0); - let mut dst = ramp(8, 999.0); - // SAFETY: both slices have length 8, a multiple of 8. - unsafe { scale_into(&mut dst, &src, 0.0) } - assert!(dst.iter().all(|&x| x == 0.0)); - } - - #[test] - fn scale_into_one_is_copy() { - let src = ramp(16, 0.3); - let mut dst = vec![0.0_f32; 16]; - // SAFETY: both slices have length 16, a multiple of 8. - unsafe { scale_into(&mut dst, &src, 1.0) } - assert_eq!(dst, src); - } - #[test] fn scale_matches_scalar() { let mut v = ramp(16, 1.0); @@ -686,14 +649,62 @@ mod tests { } } + #[test] + fn micro_4x1_matches_individual_dots() { + let d = 16; + let p0 = ramp(d, 0.1); + let p1 = ramp(d, -0.2); + let p2 = ramp(d, 0.3); + let p3 = ramp(d, -0.4); + let c = ramp(d, 0.5); + + // SAFETY: all 5 slices have length 16, a multiple of 8. + let got = unsafe { micro_4x1(&p0, &p1, &p2, &p3, &c) }; + + let expected = [ + ref_dot(&p0, &c), + ref_dot(&p1, &c), + ref_dot(&p2, &c), + ref_dot(&p3, &c), + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(*g, *e, 1e-5); + } + } + + #[test] + fn micro_4x1_d3072() { + let d = 3072; + let p0 = ramp(d, 0.001); + let p1 = ramp(d, -0.001); + let p2 = ramp(d, 0.002); + let p3 = ramp(d, -0.002); + let c = ramp(d, 0.001); + + // SAFETY: all 5 slices have length 3072, a multiple of 8. + let got = unsafe { micro_4x1(&p0, &p1, &p2, &p3, &c) }; + + let expected = [ + ref_dot(&p0, &c), + ref_dot(&p1, &c), + ref_dot(&p2, &c), + ref_dot(&p3, &c), + ]; + + for (g, e) in got.iter().zip(&expected) { + assert_close(*g, *e, 1e-3); + } + } + #[test] fn nearest4_matches_brute_force_even_k() { let d = 8; - let k = 4; + let k = nz!(4); // 4 centroids: axis-aligned unit vectors. - let mut centroids = vec![0.0_f32; k * d]; - for i in 0..k { + let mut centroids = vec![0.0_f32; k.get() * d]; + for i in 0..k.get() { centroids[i * d + i] = 1.0; } @@ -721,10 +732,10 @@ mod tests { #[test] fn nearest4_matches_brute_force_odd_k() { let d = 8; - let k = 3; // odd: exercises the remainder path + let k = nz!(3); // odd: exercises the remainder path - let mut centroids = vec![0.0_f32; k * d]; - for i in 0..k { + let mut centroids = vec![0.0_f32; k.get() * d]; + for i in 0..k.get() { centroids[i * d + i] = 1.0; } @@ -759,7 +770,7 @@ mod tests { // SAFETY: d=8 (multiple of 8), k=1 > 0, centroids has length d, // all point slices have length d. - let got = unsafe { nearest4(&p0, &p1, &p2, &p3, ¢roids, 1, d) }; + let got = unsafe { nearest4(&p0, &p1, &p2, &p3, ¢roids, nz!(1), d) }; assert_eq!(got[0].0, 0); assert_eq!(got[1].0, 0); diff --git a/libs/@local/graph/store/src/embedding/mod.rs b/libs/@local/graph/store/src/embedding/mod.rs index d008b247ce0..c370176aacc 100644 --- a/libs/@local/graph/store/src/embedding/mod.rs +++ b/libs/@local/graph/store/src/embedding/mod.rs @@ -10,4 +10,7 @@ pub mod clustering; pub mod dimension; -pub(crate) mod kernel; +// Hidden from docs: the kernel is an implementation detail, exposed only so +// the `embedding` bench target can measure it in isolation. +#[doc(hidden)] +pub mod kernel; diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index e6c43cdea12..8bf5610f40b 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -576,6 +576,11 @@ pub struct ClusterEntitiesResponse { pub clusters: Vec, /// Entities from the request that had no stored embedding. pub missing_embeddings: Vec, + /// Sum of squared chord distances from every clustered entity to its + /// assigned centroid. Lower is tighter; comparable across runs over the + /// same entities, e.g. to choose a cluster count. `0.0` when nothing was + /// clustered. + pub inertia: f32, } #[derive(Debug, Deserialize)] From 6865e313077a9e0ed31d9d77ce8016a46d6a6018 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:23:27 +0200 Subject: [PATCH 12/78] chore: diagram --- apps/hash-graph/docs/dependency-diagram.mmd | 1 + .../rust/docs/dependency-diagram.mmd | 30 ++++++++++------- .../graph/api/docs/dependency-diagram.mmd | 1 + .../authorization/docs/dependency-diagram.mmd | 30 ++++++++++------- .../embeddings/docs/dependency-diagram.mmd | 24 +++++++++----- .../docs/dependency-diagram.mmd | 32 ++++++++++++------- .../graph/store/docs/dependency-diagram.mmd | 30 ++++++++++------- libs/@local/graph/store/package.json | 1 + .../type-fetcher/docs/dependency-diagram.mmd | 24 +++++++++----- .../graph/types/docs/dependency-diagram.mmd | 30 ++++++++++------- .../validation/docs/dependency-diagram.mmd | 30 ++++++++++------- .../harpc/server/docs/dependency-diagram.mmd | 26 +++++++++------ .../hashql/ast/docs/dependency-diagram.mmd | 1 + .../compiletest/docs/dependency-diagram.mmd | 1 + .../hashql/eval/docs/dependency-diagram.mmd | 1 + .../hashql/hir/docs/dependency-diagram.mmd | 1 + .../hashql/mir/docs/dependency-diagram.mmd | 1 + .../syntax-jexpr/docs/dependency-diagram.mmd | 1 + .../docs/dependency-diagram.mmd | 30 ++++++++++------- .../rust/docs/dependency-diagram.mmd | 32 ++++++++++++------- 20 files changed, 212 insertions(+), 115 deletions(-) diff --git a/apps/hash-graph/docs/dependency-diagram.mmd b/apps/hash-graph/docs/dependency-diagram.mmd index 2c772cf559f..ec8e7669a4d 100644 --- a/apps/hash-graph/docs/dependency-diagram.mmd +++ b/apps/hash-graph/docs/dependency-diagram.mmd @@ -72,6 +72,7 @@ graph TD 10 --> 5 10 --> 14 10 --> 35 + 10 -.-> 37 11 --> 2 12 --> 33 13 --> 10 diff --git a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd index d97925ceaff..3cbfa718cbd 100644 --- a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd +++ b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd @@ -32,13 +32,17 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[error-stack] - 24[hash-graph-benches] - 25[hash-graph-integration] - 26[hash-graph-test-data] + 23[darwin-kperf] + 24[darwin-kperf-criterion] + 25[darwin-kperf-events] + 26[darwin-kperf-sys] + 27[error-stack] + 28[hash-graph-benches] + 29[hash-graph-integration] + 30[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 26 + 1 -.-> 30 2 -.-> 3 2 --> 15 4 --> 6 @@ -52,15 +56,16 @@ graph TD 8 --> 5 8 --> 11 8 --> 22 + 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 26 - 12 -.-> 26 + 11 -.-> 30 + 12 -.-> 30 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 23 + 15 --> 27 16 -.-> 17 17 --> 18 17 --> 21 @@ -70,6 +75,9 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 24 -.-> 4 - 25 -.-> 7 - 26 --> 8 + 23 --> 25 + 23 --> 26 + 24 --> 23 + 28 -.-> 4 + 29 -.-> 7 + 30 --> 8 diff --git a/libs/@local/graph/api/docs/dependency-diagram.mmd b/libs/@local/graph/api/docs/dependency-diagram.mmd index 3ec3bd7bc71..ec3530323a2 100644 --- a/libs/@local/graph/api/docs/dependency-diagram.mmd +++ b/libs/@local/graph/api/docs/dependency-diagram.mmd @@ -73,6 +73,7 @@ graph TD 10 --> 5 10 --> 14 10 --> 35 + 10 -.-> 37 11 --> 2 12 --> 33 13 --> 10 diff --git a/libs/@local/graph/authorization/docs/dependency-diagram.mmd b/libs/@local/graph/authorization/docs/dependency-diagram.mmd index 9b7424d94ae..002c88588d2 100644 --- a/libs/@local/graph/authorization/docs/dependency-diagram.mmd +++ b/libs/@local/graph/authorization/docs/dependency-diagram.mmd @@ -32,13 +32,17 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[error-stack] - 24[hash-graph-benches] - 25[hash-graph-integration] - 26[hash-graph-test-data] + 23[darwin-kperf] + 24[darwin-kperf-criterion] + 25[darwin-kperf-events] + 26[darwin-kperf-sys] + 27[error-stack] + 28[hash-graph-benches] + 29[hash-graph-integration] + 30[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 26 + 1 -.-> 30 2 -.-> 3 2 --> 15 4 --> 6 @@ -52,15 +56,16 @@ graph TD 8 --> 5 8 --> 11 8 --> 22 + 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 26 - 12 -.-> 26 + 11 -.-> 30 + 12 -.-> 30 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 23 + 15 --> 27 16 -.-> 17 17 --> 18 17 --> 21 @@ -70,6 +75,9 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 24 -.-> 4 - 25 -.-> 7 - 26 --> 8 + 23 --> 25 + 23 --> 26 + 24 --> 23 + 28 -.-> 4 + 29 -.-> 7 + 30 --> 8 diff --git a/libs/@local/graph/embeddings/docs/dependency-diagram.mmd b/libs/@local/graph/embeddings/docs/dependency-diagram.mmd index 1ebad383105..97ab6d97b76 100644 --- a/libs/@local/graph/embeddings/docs/dependency-diagram.mmd +++ b/libs/@local/graph/embeddings/docs/dependency-diagram.mmd @@ -22,12 +22,16 @@ graph TD 10[harpc-types] 11[harpc-wire-protocol] 12[hash-temporal-client] - 13[error-stack] - 14[hash-graph-benches] - 15[hash-graph-test-data] + 13[darwin-kperf] + 14[darwin-kperf-criterion] + 15[darwin-kperf-events] + 16[darwin-kperf-sys] + 17[error-stack] + 18[hash-graph-benches] + 19[hash-graph-test-data] 0 --> 4 1 --> 8 - 1 -.-> 15 + 1 -.-> 19 2 -.-> 3 2 --> 11 4 --> 6 @@ -37,11 +41,15 @@ graph TD 7 --> 5 7 --> 9 7 --> 12 + 7 -.-> 14 8 --> 2 - 9 -.-> 15 + 9 -.-> 19 11 -.-> 10 11 --> 10 - 11 --> 13 + 11 --> 17 12 --> 1 - 14 -.-> 4 - 15 --> 7 + 13 --> 15 + 13 --> 16 + 14 --> 13 + 18 -.-> 4 + 19 --> 7 diff --git a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd index 84ed6a1efa0..d26e8f3bc11 100644 --- a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd @@ -33,13 +33,17 @@ graph TD 21[hash-status] 22[hash-telemetry] 23[hash-temporal-client] - 24[error-stack] - 25[hash-graph-benches] - 26[hash-graph-integration] - 27[hash-graph-test-data] + 24[darwin-kperf] + 25[darwin-kperf-criterion] + 26[darwin-kperf-events] + 27[darwin-kperf-sys] + 28[error-stack] + 29[hash-graph-benches] + 30[hash-graph-integration] + 31[hash-graph-test-data] 0 --> 4 1 --> 10 - 1 -.-> 27 + 1 -.-> 31 2 -.-> 3 2 --> 14 4 --> 17 @@ -53,12 +57,13 @@ graph TD 9 --> 5 9 --> 11 9 --> 23 + 9 -.-> 25 10 --> 2 - 11 -.-> 27 - 12 -.-> 27 + 11 -.-> 31 + 12 -.-> 31 14 -.-> 13 14 --> 13 - 14 --> 24 + 14 --> 28 15 -.-> 16 16 --> 17 16 --> 20 @@ -67,8 +72,11 @@ graph TD 18 -.-> 16 19 --> 18 20 --> 15 - 22 --> 24 + 22 --> 28 23 --> 1 - 25 -.-> 4 - 26 -.-> 8 - 27 --> 9 + 24 --> 26 + 24 --> 27 + 25 --> 24 + 29 -.-> 4 + 30 -.-> 8 + 31 --> 9 diff --git a/libs/@local/graph/store/docs/dependency-diagram.mmd b/libs/@local/graph/store/docs/dependency-diagram.mmd index 0ba92b32822..c25fc15d926 100644 --- a/libs/@local/graph/store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/store/docs/dependency-diagram.mmd @@ -32,13 +32,17 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[error-stack] - 24[hash-graph-benches] - 25[hash-graph-integration] - 26[hash-graph-test-data] + 23[darwin-kperf] + 24[darwin-kperf-criterion] + 25[darwin-kperf-events] + 26[darwin-kperf-sys] + 27[error-stack] + 28[hash-graph-benches] + 29[hash-graph-integration] + 30[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 26 + 1 -.-> 30 2 -.-> 3 2 --> 15 4 --> 6 @@ -52,15 +56,16 @@ graph TD 8 --> 5 8 --> 11 8 --> 22 + 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 26 - 12 -.-> 26 + 11 -.-> 30 + 12 -.-> 30 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 23 + 15 --> 27 16 -.-> 17 17 --> 18 17 --> 21 @@ -70,6 +75,9 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 24 -.-> 4 - 25 -.-> 7 - 26 --> 8 + 23 --> 25 + 23 --> 26 + 24 --> 23 + 28 -.-> 4 + 29 -.-> 7 + 30 --> 8 diff --git a/libs/@local/graph/store/package.json b/libs/@local/graph/store/package.json index 13a00b5532b..04978d129c6 100644 --- a/libs/@local/graph/store/package.json +++ b/libs/@local/graph/store/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@local/tsconfig": "workspace:*", + "@rust/darwin-kperf-criterion": "workspace:*", "@rust/hash-codegen": "workspace:*", "typescript": "5.9.3" } diff --git a/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd b/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd index 5c3c10fd8c7..d1243620b21 100644 --- a/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd +++ b/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd @@ -22,12 +22,16 @@ graph TD 10[harpc-types] 11[harpc-wire-protocol] 12[hash-temporal-client] - 13[error-stack] - 14[hash-graph-benches] - 15[hash-graph-test-data] + 13[darwin-kperf] + 14[darwin-kperf-criterion] + 15[darwin-kperf-events] + 16[darwin-kperf-sys] + 17[error-stack] + 18[hash-graph-benches] + 19[hash-graph-test-data] 0 --> 4 1 --> 7 - 1 -.-> 15 + 1 -.-> 19 2 -.-> 3 2 --> 11 4 --> 8 @@ -35,12 +39,16 @@ graph TD 6 --> 5 6 --> 9 6 --> 12 + 6 -.-> 14 7 --> 2 8 --> 6 - 9 -.-> 15 + 9 -.-> 19 11 -.-> 10 11 --> 10 - 11 --> 13 + 11 --> 17 12 --> 1 - 14 -.-> 4 - 15 --> 6 + 13 --> 15 + 13 --> 16 + 14 --> 13 + 18 -.-> 4 + 19 --> 6 diff --git a/libs/@local/graph/types/docs/dependency-diagram.mmd b/libs/@local/graph/types/docs/dependency-diagram.mmd index 64f359477cc..1b887fb64f8 100644 --- a/libs/@local/graph/types/docs/dependency-diagram.mmd +++ b/libs/@local/graph/types/docs/dependency-diagram.mmd @@ -32,13 +32,17 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[error-stack] - 24[hash-graph-benches] - 25[hash-graph-integration] - 26[hash-graph-test-data] + 23[darwin-kperf] + 24[darwin-kperf-criterion] + 25[darwin-kperf-events] + 26[darwin-kperf-sys] + 27[error-stack] + 28[hash-graph-benches] + 29[hash-graph-integration] + 30[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 26 + 1 -.-> 30 2 -.-> 3 2 --> 15 4 --> 6 @@ -52,15 +56,16 @@ graph TD 8 --> 5 8 --> 11 8 --> 22 + 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 26 - 12 -.-> 26 + 11 -.-> 30 + 12 -.-> 30 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 23 + 15 --> 27 16 -.-> 17 17 --> 18 17 --> 21 @@ -70,6 +75,9 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 24 -.-> 4 - 25 -.-> 7 - 26 --> 8 + 23 --> 25 + 23 --> 26 + 24 --> 23 + 28 -.-> 4 + 29 -.-> 7 + 30 --> 8 diff --git a/libs/@local/graph/validation/docs/dependency-diagram.mmd b/libs/@local/graph/validation/docs/dependency-diagram.mmd index a84b3c63ba9..4dd6fa9d4c7 100644 --- a/libs/@local/graph/validation/docs/dependency-diagram.mmd +++ b/libs/@local/graph/validation/docs/dependency-diagram.mmd @@ -29,13 +29,17 @@ graph TD 17[hashql-mir] 18[hashql-syntax-jexpr] 19[hash-temporal-client] - 20[error-stack] - 21[hash-graph-benches] - 22[hash-graph-integration] - 23[hash-graph-test-data] + 20[darwin-kperf] + 21[darwin-kperf-criterion] + 22[darwin-kperf-events] + 23[darwin-kperf-sys] + 24[error-stack] + 25[hash-graph-benches] + 26[hash-graph-integration] + 27[hash-graph-test-data] 0 --> 4 1 --> 8 - 1 -.-> 23 + 1 -.-> 27 2 -.-> 3 2 --> 12 4 --> 15 @@ -45,12 +49,13 @@ graph TD 7 --> 5 7 --> 9 7 --> 19 + 7 -.-> 21 8 --> 2 - 9 -.-> 23 - 10 -.-> 23 + 9 -.-> 27 + 10 -.-> 27 12 -.-> 11 12 --> 11 - 12 --> 20 + 12 --> 24 13 -.-> 14 14 --> 15 14 --> 18 @@ -60,6 +65,9 @@ graph TD 17 --> 16 18 --> 13 19 --> 1 - 21 -.-> 4 - 22 -.-> 6 - 23 --> 7 + 20 --> 22 + 20 --> 23 + 21 --> 20 + 25 -.-> 4 + 26 -.-> 6 + 27 --> 7 diff --git a/libs/@local/harpc/server/docs/dependency-diagram.mmd b/libs/@local/harpc/server/docs/dependency-diagram.mmd index c55bd6573e3..0f088c57070 100644 --- a/libs/@local/harpc/server/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/server/docs/dependency-diagram.mmd @@ -27,12 +27,16 @@ graph TD 15[harpc-types] 16[harpc-wire-protocol] 17[hash-temporal-client] - 18[error-stack] - 19[hash-graph-benches] - 20[hash-graph-test-data] + 18[darwin-kperf] + 19[darwin-kperf-criterion] + 20[darwin-kperf-events] + 21[darwin-kperf-sys] + 22[error-stack] + 23[hash-graph-benches] + 24[hash-graph-test-data] 0 --> 4 1 --> 7 - 1 -.-> 20 + 1 -.-> 24 2 -.-> 3 2 --> 16 4 --> 6 @@ -41,11 +45,12 @@ graph TD 6 --> 5 6 --> 8 6 --> 17 + 6 -.-> 19 7 --> 2 - 8 -.-> 20 + 8 -.-> 24 9 --> 13 10 --> 15 - 10 --> 18 + 10 --> 22 11 --> 2 11 -.-> 10 11 --> 10 @@ -56,7 +61,10 @@ graph TD 14 --> 11 16 -.-> 15 16 --> 15 - 16 --> 18 + 16 --> 22 17 --> 1 - 19 -.-> 4 - 20 --> 6 + 18 --> 20 + 18 --> 21 + 19 --> 18 + 23 -.-> 4 + 24 --> 6 diff --git a/libs/@local/hashql/ast/docs/dependency-diagram.mmd b/libs/@local/hashql/ast/docs/dependency-diagram.mmd index 1c4dfe4d89f..1d809d023df 100644 --- a/libs/@local/hashql/ast/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/ast/docs/dependency-diagram.mmd @@ -59,6 +59,7 @@ graph TD 9 --> 5 9 --> 11 9 --> 26 + 9 -.-> 28 10 --> 2 11 -.-> 33 12 -.-> 33 diff --git a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd index 4d2d74f3e20..7fa03c59383 100644 --- a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd @@ -59,6 +59,7 @@ graph TD 9 --> 5 9 --> 11 9 --> 26 + 9 -.-> 28 10 --> 2 11 -.-> 33 12 -.-> 33 diff --git a/libs/@local/hashql/eval/docs/dependency-diagram.mmd b/libs/@local/hashql/eval/docs/dependency-diagram.mmd index 09fbd7269eb..ba720211915 100644 --- a/libs/@local/hashql/eval/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/eval/docs/dependency-diagram.mmd @@ -59,6 +59,7 @@ graph TD 9 --> 5 9 --> 11 9 --> 26 + 9 -.-> 28 10 --> 2 11 -.-> 33 12 -.-> 33 diff --git a/libs/@local/hashql/hir/docs/dependency-diagram.mmd b/libs/@local/hashql/hir/docs/dependency-diagram.mmd index 979a0dc9dc4..31c175f2525 100644 --- a/libs/@local/hashql/hir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/hir/docs/dependency-diagram.mmd @@ -59,6 +59,7 @@ graph TD 9 --> 5 9 --> 11 9 --> 26 + 9 -.-> 28 10 --> 2 11 -.-> 33 12 -.-> 33 diff --git a/libs/@local/hashql/mir/docs/dependency-diagram.mmd b/libs/@local/hashql/mir/docs/dependency-diagram.mmd index 58b887e6182..351c3ebd1f6 100644 --- a/libs/@local/hashql/mir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/mir/docs/dependency-diagram.mmd @@ -59,6 +59,7 @@ graph TD 9 --> 5 9 --> 11 9 --> 26 + 9 -.-> 28 10 --> 2 11 -.-> 33 12 -.-> 33 diff --git a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd index d4de1967175..1abbd2f75d5 100644 --- a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd @@ -59,6 +59,7 @@ graph TD 9 --> 5 9 --> 11 9 --> 26 + 9 -.-> 28 10 --> 2 11 -.-> 33 12 -.-> 33 diff --git a/libs/@local/temporal-client/docs/dependency-diagram.mmd b/libs/@local/temporal-client/docs/dependency-diagram.mmd index 65bcb84bf66..1152c9ec8ed 100644 --- a/libs/@local/temporal-client/docs/dependency-diagram.mmd +++ b/libs/@local/temporal-client/docs/dependency-diagram.mmd @@ -32,13 +32,17 @@ graph TD 21[hashql-syntax-jexpr] 22[hash-temporal-client] class 22 root - 23[error-stack] - 24[hash-graph-benches] - 25[hash-graph-integration] - 26[hash-graph-test-data] + 23[darwin-kperf] + 24[darwin-kperf-criterion] + 25[darwin-kperf-events] + 26[darwin-kperf-sys] + 27[error-stack] + 28[hash-graph-benches] + 29[hash-graph-integration] + 30[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 26 + 1 -.-> 30 2 -.-> 3 2 --> 15 4 --> 6 @@ -52,15 +56,16 @@ graph TD 8 --> 5 8 --> 11 8 --> 22 + 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 26 - 12 -.-> 26 + 11 -.-> 30 + 12 -.-> 30 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 23 + 15 --> 27 16 -.-> 17 17 --> 18 17 --> 21 @@ -70,6 +75,9 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 24 -.-> 4 - 25 -.-> 7 - 26 --> 8 + 23 --> 25 + 23 --> 26 + 24 --> 23 + 28 -.-> 4 + 29 -.-> 7 + 30 --> 8 diff --git a/tests/graph/test-data/rust/docs/dependency-diagram.mmd b/tests/graph/test-data/rust/docs/dependency-diagram.mmd index 8418e7f6ed1..fc41eb6288a 100644 --- a/tests/graph/test-data/rust/docs/dependency-diagram.mmd +++ b/tests/graph/test-data/rust/docs/dependency-diagram.mmd @@ -31,14 +31,18 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[error-stack] - 24[hash-graph-benches] - 25[hash-graph-integration] - 26[hash-graph-test-data] - class 26 root + 23[darwin-kperf] + 24[darwin-kperf-criterion] + 25[darwin-kperf-events] + 26[darwin-kperf-sys] + 27[error-stack] + 28[hash-graph-benches] + 29[hash-graph-integration] + 30[hash-graph-test-data] + class 30 root 0 --> 4 1 --> 9 - 1 -.-> 26 + 1 -.-> 30 2 -.-> 3 2 --> 15 4 --> 6 @@ -52,15 +56,16 @@ graph TD 8 --> 5 8 --> 11 8 --> 22 + 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 26 - 12 -.-> 26 + 11 -.-> 30 + 12 -.-> 30 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 23 + 15 --> 27 16 -.-> 17 17 --> 18 17 --> 21 @@ -70,6 +75,9 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 24 -.-> 4 - 25 -.-> 7 - 26 --> 8 + 23 --> 25 + 23 --> 26 + 24 --> 23 + 28 -.-> 4 + 29 -.-> 7 + 30 --> 8 From fa9b35a2f6894ea4f05b2c8bbf57b668efbdef47 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:23:45 +0200 Subject: [PATCH 13/78] chore: lockfile --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 8e112e18814..cb4ccc74b26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13215,6 +13215,7 @@ __metadata: dependencies: "@blockprotocol/type-system-rs": "workspace:*" "@local/tsconfig": "workspace:*" + "@rust/darwin-kperf-criterion": "workspace:*" "@rust/error-stack": "workspace:*" "@rust/hash-codec": "workspace:*" "@rust/hash-codegen": "workspace:*" From 2bfd8badad73eaf5a9f4707f8b31ebbccdf3d0f4 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:20:37 +0200 Subject: [PATCH 14/78] chore: fix schema --- libs/@local/graph/api/openapi/openapi.json | 3 ++- libs/@local/graph/store/src/entity/store.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index a93edfd1e59..2839c9c7d79 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -3809,7 +3809,8 @@ "description": "Embedding dimension after matryoshka truncation. Must be a positive\nmultiple of 8; values above 3072 are rejected. Defaults to 256.", "default": 256, "example": 256, - "minimum": 1 + "multipleOf": 8, + "minimum": 8 }, "entityIds": { "type": "array", diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 8bf5610f40b..745333971a5 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -539,7 +539,7 @@ pub struct ClusterEntitiesParams { /// Embedding dimension after matryoshka truncation. Must be a positive /// multiple of 8; values above 3072 are rejected. Defaults to 256. #[serde(default = "ClusterEntitiesParams::default_dimension")] - #[cfg_attr(feature = "utoipa", schema(value_type = u16, minimum = 1, default = 256, example = 256))] + #[cfg_attr(feature = "utoipa", schema(value_type = u16, minimum = 8, multiple_of = 8, default = 256, example = 256))] pub dimension: NonZero, /// Seed for the random number generator used in clustering. From 3cc5897e20d8d88f0b4135f1785bc53ba37511b0 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:57:06 +0200 Subject: [PATCH 15/78] feat: add scripts --- libs/@local/graph/store/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/@local/graph/store/package.json b/libs/@local/graph/store/package.json index 04978d129c6..2fb5ede493d 100644 --- a/libs/@local/graph/store/package.json +++ b/libs/@local/graph/store/package.json @@ -15,11 +15,14 @@ "./types": "./types/index.snap.js" }, "scripts": { + "build:codspeed": "cargo codspeed build -p hash-graph-store", "build:types": "INSTA_UPDATE=always mise exec --env dev cargo:cargo-insta -- cargo-insta test --features codegen --test codegen", "doc:dependency-diagram": "cargo run -p hash-repo-chores -- dependency-diagram --output docs/dependency-diagram.mmd --root hash-graph-store --root-deps-and-dependents --link-mode non-roots --include-dev-deps --include-build-deps --logging-console-level info", "fix:clippy": "just clippy --fix", "lint:clippy": "just clippy", "lint:tsc": "tsc --noEmit", + "test:codspeed": "cargo codspeed run -p hash-graph-store", + "test:miri": "cargo miri nextest run -- embedding::kernel embedding::clustering::tests::nearest_centroid_argmax_independent_of_inv_norm", "test:unit": "mise run test:unit @rust/hash-graph-store" }, "dependencies": { From 7a8ddad5ffe315c8b8ffe1326c0057e0377cd73e Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:14:55 +0200 Subject: [PATCH 16/78] fix: warm up repository on CI --- .github/workflows/codspeed.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 8a824c166ab..867aced9ae0 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -70,6 +70,9 @@ jobs: with: scope: ${{ matrix.name }} + - name: Warm up repository + uses: ./.github/actions/warm-up-repo + - name: Build the benchmark target run: turbo run build:codspeed --filter=${{ matrix.name }} From 336dd2f17c677df889f02009de376eb8689e2916 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:30:10 +0200 Subject: [PATCH 17/78] fix: docs --- libs/@local/graph/api/openapi/openapi.json | 2 +- libs/@local/graph/store/src/entity/store.rs | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 2839c9c7d79..f32b0eb637b 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -4638,7 +4638,7 @@ "type": "number", "format": "float" }, - "description": "Unit-normalized centroid with length equal to the requested dimension." + "description": "Centroid with length equal to the requested dimension.\n\nTypically unit-normalized, but may be the all-zero vector if all assigned points have zero\nnorm." }, "clusterId": { "type": "integer", diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 745333971a5..14ecc7a08ee 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -562,7 +562,10 @@ pub struct EntityCluster { /// Index in `0..cluster_count`. pub cluster_id: u16, pub entity_ids: Vec, - /// Unit-normalized centroid with length equal to the requested dimension. + /// Centroid with length equal to the requested dimension. + /// + /// Typically unit-normalized, but may be the all-zero vector if all assigned points have zero + /// norm. pub centroid: Vec, } From 4685963995cfcb253f2b00f53d2f252775675d2c Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:04:38 +0200 Subject: [PATCH 18/78] chore: bound the threads inside of benchmarking + thread pool limit --- Cargo.lock | 1 + apps/hash-graph/Cargo.toml | 1 + apps/hash-graph/src/subcommand/mod.rs | 13 ++++++++++++- libs/@local/graph/store/benches/embedding.rs | 5 +++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8c32c3395bf..3045f2d5588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3527,6 +3527,7 @@ dependencies = [ "jsonwebtoken", "mimalloc", "multiaddr", + "rayon", "regex", "reqwest", "simple-mermaid", diff --git a/apps/hash-graph/Cargo.toml b/apps/hash-graph/Cargo.toml index 0dcb10e4746..02b4f58de69 100644 --- a/apps/hash-graph/Cargo.toml +++ b/apps/hash-graph/Cargo.toml @@ -34,6 +34,7 @@ futures = { workspace = true } jsonwebtoken = { workspace = true } mimalloc = { workspace = true } multiaddr = { workspace = true } +rayon = { workspace = true } regex = { workspace = true } reqwest = { workspace = true, features = ["rustls"] } simple-mermaid = { workspace = true } diff --git a/apps/hash-graph/src/subcommand/mod.rs b/apps/hash-graph/src/subcommand/mod.rs index 08931291972..e6e7ca8e334 100644 --- a/apps/hash-graph/src/subcommand/mod.rs +++ b/apps/hash-graph/src/subcommand/mod.rs @@ -7,7 +7,7 @@ mod snapshot; mod type_fetcher; use core::time::Duration; -use std::time::Instant; +use std::{thread::available_parallelism, time::Instant}; use clap::Parser; use error_stack::{Report, ensure}; @@ -141,11 +141,22 @@ pub enum Subcommand { ReindexCache(Box), } +#[expect(clippy::integer_division, clippy::integer_division_remainder_used)] fn block_on( future: impl Future>>, service_name: &'static str, tracing_config: TracingConfig, ) -> Result<(), Report> { + rayon::ThreadPoolBuilder::new() + .num_threads( + available_parallelism() + .map_or(1, |cores| cores.get() / 2) + .max(1), + ) + .thread_name(|index| format!("rayon-{index}")) + .build_global() + .expect("rayon pool should be initialized exactly once"); + tokio::runtime::Builder::new_multi_thread() .enable_all() .build() diff --git a/libs/@local/graph/store/benches/embedding.rs b/libs/@local/graph/store/benches/embedding.rs index 13094b45589..2bd5b53692b 100644 --- a/libs/@local/graph/store/benches/embedding.rs +++ b/libs/@local/graph/store/benches/embedding.rs @@ -179,6 +179,11 @@ fn bench_nearest4(criterion: &mut Criterion) { } fn bench_cluster(criterion: &mut Criterion) { + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build_global() + .expect("should be built exactly once"); + let mut group = criterion.benchmark_group("embedding/cluster"); group.sample_size(10); From fa9c9da1eb837a148644b8dbcfdc94893296d44f Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:40:46 +0200 Subject: [PATCH 19/78] feat: limit request + remove clustering unsafe --- .../store/postgres/knowledge/entity/mod.rs | 18 +++++++++--- .../graph/store/src/embedding/clustering.rs | 29 +++++-------------- libs/@local/graph/store/src/embedding/mod.rs | 4 +-- libs/@local/graph/store/src/entity/store.rs | 15 ++++++---- libs/@local/graph/store/src/error.rs | 2 ++ 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 4faecf6befb..d69d276cfaf 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2599,7 +2599,7 @@ where Ok(permitted_ids) } - #[expect(clippy::too_many_lines, clippy::cast_possible_truncation)] + #[expect(clippy::too_many_lines)] #[tracing::instrument(skip(self, params))] async fn cluster_entities( &self, @@ -2607,7 +2607,8 @@ where params: ClusterEntitiesParams, ) -> Result> { const { assert!(Embedding::DIM <= u16::MAX as usize) }; - const STORED_DIM: u16 = Embedding::DIM as u16; + const MAX_ALLOWED_DIM: u16 = 512; + const MAX_ALLOWED_K: u16 = 64; let dimension = Dimension::new(params.dimension.get()).ok_or_else(|| { Report::new(ClusterError::InvalidDimension { @@ -2616,13 +2617,22 @@ where .attach(StatusCode::InvalidArgument) })?; - if dimension.get() > STORED_DIM { + if dimension.get() > MAX_ALLOWED_DIM { return Err(Report::new(ClusterError::DimensionTooLarge { dimension: dimension.value(), - max: STORED_DIM, + max: MAX_ALLOWED_DIM, + }) + .attach(StatusCode::InvalidArgument)); + } + + if params.cluster_count > MAX_ALLOWED_K { + return Err(Report::new(ClusterError::KTooLarge { + count: params.cluster_count, + max: MAX_ALLOWED_K, }) .attach(StatusCode::InvalidArgument)); } + let truncated_dim = usize::from(dimension.get()); // Filter to entities the actor is allowed to view. diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index 0ff02308ee7..03a0574fe9a 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -100,13 +100,8 @@ pub struct Clustering { impl Clustering { /// Allocates a zeroed clustering for `k` centroids over `n` points. fn new(k: u16, n: usize, d: Dimension) -> Self { - // SAFETY: All-zero bits are valid for `f32` (IEEE 754 positive zero) - // and for `u16` (the integer 0). `Box::new_zeroed_slice` allocates - // zeroed memory of the correct layout, so `assume_init` is sound. - let centroids: Box<[f32]> = - unsafe { Box::new_zeroed_slice((k as usize) * (d.get() as usize)).assume_init() }; - // SAFETY: All-zero bits are valid for `u16` (the integer 0). - let labels: Box<[u16]> = unsafe { Box::new_zeroed_slice(n).assume_init() }; + let centroids: Box<[f32]> = vec![0.0; (k as usize) * (d.get() as usize)].into_boxed_slice(); + let labels: Box<[u16]> = vec![0; n].into_boxed_slice(); Self { centroids, @@ -329,20 +324,12 @@ struct Restart { impl Restart { fn new(k: NonZero, m: usize, d: usize) -> Self { - // SAFETY: all-zero bits are valid for `f32` (IEEE 754 +0.0), `usize` (0), `u16` (0), and - // `bool` (false). `Box::new_zeroed_slice` allocates zeroed memory of the correct - // layout for each type, so `assume_init` is sound in every case. - let centroids = unsafe { Box::<[f32]>::new_zeroed_slice(k.get() * d).assume_init() }; - // SAFETY: see above - let sums = unsafe { Box::<[f32]>::new_zeroed_slice(k.get() * d).assume_init() }; - // SAFETY: see above - let counts = unsafe { Box::<[usize]>::new_zeroed_slice(k.get()).assume_init() }; - // SAFETY: see above - let labels = unsafe { Box::<[u16]>::new_zeroed_slice(m).assume_init() }; - // SAFETY: see above - let point_distances = unsafe { Box::<[f32]>::new_zeroed_slice(m).assume_init() }; - // SAFETY: see above - let selected = unsafe { Box::<[bool]>::new_zeroed_slice(m).assume_init() }; + let centroids: Box<[f32]> = vec![0.0; k.get() * d].into_boxed_slice(); + let sums: Box<[f32]> = vec![0.0; k.get() * d].into_boxed_slice(); + let counts: Box<[usize]> = vec![0; k.get()].into_boxed_slice(); + let labels: Box<[u16]> = vec![0; m].into_boxed_slice(); + let point_distances: Box<[f32]> = vec![0.0; m].into_boxed_slice(); + let selected: Box<[bool]> = vec![false; m].into_boxed_slice(); Self { k, diff --git a/libs/@local/graph/store/src/embedding/mod.rs b/libs/@local/graph/store/src/embedding/mod.rs index c370176aacc..d184500214f 100644 --- a/libs/@local/graph/store/src/embedding/mod.rs +++ b/libs/@local/graph/store/src/embedding/mod.rs @@ -4,8 +4,8 @@ clippy::float_arithmetic, clippy::min_ident_chars, clippy::many_single_char_names, - reason = "embedding module is under active development. Single-char idents (k, n, m, d, x) \ - are standard mathematical notation for clustering." + reason = "Single-char idents (k, n, m, d, x) are standard mathematical notation for \ + clustering." )] pub mod clustering; diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 14ecc7a08ee..59c5532b7c8 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -533,13 +533,16 @@ impl PatchEntityParams { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ClusterEntitiesParams { pub entity_ids: Vec, - /// Desired number of clusters. Clamped to the number of entities with - /// embeddings when that is smaller. + /// Desired number of clusters. + /// + /// Clamped to the number of entities with embeddings when that is smaller. + #[cfg_attr(feature = "utoipa", schema(minimum = 0, maximum = 64))] pub cluster_count: u16, - /// Embedding dimension after matryoshka truncation. Must be a positive - /// multiple of 8; values above 3072 are rejected. Defaults to 256. + /// Embedding dimension after matryoshka truncation. + /// + /// Must be a positive multiple of 8; values above 512 are rejected. Defaults to 256. #[serde(default = "ClusterEntitiesParams::default_dimension")] - #[cfg_attr(feature = "utoipa", schema(value_type = u16, minimum = 8, multiple_of = 8, default = 256, example = 256))] + #[cfg_attr(feature = "utoipa", schema(value_type = u16, minimum = 8, maximum = 512, multiple_of = 8, default = 256, example = 256))] pub dimension: NonZero, /// Seed for the random number generator used in clustering. @@ -559,7 +562,7 @@ impl ClusterEntitiesParams { #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct EntityCluster { - /// Index in `0..cluster_count`. + /// Index in `0..min(cluster_count, n)`. pub cluster_id: u16, pub entity_ids: Vec, /// Centroid with length equal to the requested dimension. diff --git a/libs/@local/graph/store/src/error.rs b/libs/@local/graph/store/src/error.rs index 3f1b684167e..35c35971d67 100644 --- a/libs/@local/graph/store/src/error.rs +++ b/libs/@local/graph/store/src/error.rs @@ -78,6 +78,8 @@ pub enum ClusterError { InvalidDimension { dimension: NonZero }, #[display("dimension {dimension} exceeds stored embedding dimension {max}")] DimensionTooLarge { dimension: NonZero, max: u16 }, + #[display("cluster count {count} exceeds maximum allowed {max}")] + KTooLarge { count: u16, max: u16 }, #[display("embedding query failed")] Store, } From 505d16aa91cc7662a0dd807a4dfd5c97b0200aeb Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:40:44 +0200 Subject: [PATCH 20/78] fix: wording --- libs/@local/graph/store/src/entity/store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 59c5532b7c8..cfd95df07c3 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -580,7 +580,7 @@ pub struct ClusterEntitiesResponse { /// One entry per non-empty cluster. Empty clusters (no points assigned) /// are omitted. pub clusters: Vec, - /// Entities from the request that had no stored embedding. + /// Entities from the request that had no stored embedding or that do not exist. pub missing_embeddings: Vec, /// Sum of squared chord distances from every clustered entity to its /// assigned centroid. Lower is tighter; comparable across runs over the From 7ebfe44837bd44762a091bffa2d42649c6a676fe Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:41:21 +0200 Subject: [PATCH 21/78] chore: regen openapi --- libs/@local/graph/api/openapi/openapi.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index f32b0eb637b..332ce2efcaa 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -3800,16 +3800,18 @@ "clusterCount": { "type": "integer", "format": "int32", - "description": "Desired number of clusters. Clamped to the number of entities with\nembeddings when that is smaller.", + "description": "Desired number of clusters.\n\nClamped to the number of entities with embeddings when that is smaller.", + "maximum": 64, "minimum": 0 }, "dimension": { "type": "integer", "format": "int32", - "description": "Embedding dimension after matryoshka truncation. Must be a positive\nmultiple of 8; values above 3072 are rejected. Defaults to 256.", + "description": "Embedding dimension after matryoshka truncation.\n\nMust be a positive multiple of 8; values above 512 are rejected. Defaults to 256.", "default": 256, "example": 256, "multipleOf": 8, + "maximum": 512, "minimum": 8 }, "entityIds": { @@ -3854,7 +3856,7 @@ "items": { "$ref": "#/components/schemas/EntityId" }, - "description": "Entities from the request that had no stored embedding." + "description": "Entities from the request that had no stored embedding or that do not exist." } } }, @@ -4643,7 +4645,7 @@ "clusterId": { "type": "integer", "format": "int32", - "description": "Index in `0..cluster_count`.", + "description": "Index in `0..min(cluster_count, n)`.", "minimum": 0 }, "entityIds": { From 60603e4d7f628682cf64723c93157b2d4c0a0c96 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:03:09 +0200 Subject: [PATCH 22/78] chore: docs --- libs/@local/graph/store/src/embedding/clustering.rs | 4 ++++ libs/@local/graph/store/src/entity/store.rs | 2 +- libs/@local/graph/store/src/error.rs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index 03a0574fe9a..5db1bcb979b 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -635,6 +635,10 @@ unsafe fn accumulate_clusters( // kernel call it precedes. debug_assert!(inv_norms.is_none_or(|norms| norms.len() == labels.len())); + // TODO(PERF): This isn't as performant as it could be, in theory we could first create a + // histogram, and then use that to dispatch over it. A potential optimization opportunity if + // ever required. + sums.par_chunks_exact_mut(d) .zip(counts.par_iter_mut()) .enumerate() diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index cfd95df07c3..063cce3a7ec 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -989,7 +989,7 @@ pub trait EntityStore { /// /// Returns [`ClusterError::InvalidDimension`] if the dimension is not a /// positive multiple of 8, [`ClusterError::DimensionTooLarge`] if it - /// exceeds the stored embedding width, or [`ClusterError::Store`] if the + /// exceeds the maximum allowed dimension, or [`ClusterError::Store`] if the /// embedding query fails. fn cluster_entities( &self, diff --git a/libs/@local/graph/store/src/error.rs b/libs/@local/graph/store/src/error.rs index 35c35971d67..546e8db5e09 100644 --- a/libs/@local/graph/store/src/error.rs +++ b/libs/@local/graph/store/src/error.rs @@ -76,7 +76,7 @@ impl Error for CheckPermissionError {} pub enum ClusterError { #[display("dimension {dimension} is not a positive multiple of 8")] InvalidDimension { dimension: NonZero }, - #[display("dimension {dimension} exceeds stored embedding dimension {max}")] + #[display("dimension {dimension} exceeds maximum allowed dimension {max}")] DimensionTooLarge { dimension: NonZero, max: u16 }, #[display("cluster count {count} exceeds maximum allowed {max}")] KTooLarge { count: u16, max: u16 }, From 7c1dcbe8e5c51839c3f831ed254d8e17bd5ab60c Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:31:11 +0200 Subject: [PATCH 23/78] feat: change the accumulate clusters method --- libs/@local/graph/store/benches/embedding.rs | 14 +- .../graph/store/src/embedding/clustering.rs | 445 +++++++++++++----- .../graph/store/src/embedding/kernel.rs | 29 +- 3 files changed, 368 insertions(+), 120 deletions(-) diff --git a/libs/@local/graph/store/benches/embedding.rs b/libs/@local/graph/store/benches/embedding.rs index 2bd5b53692b..d5dc2268cc3 100644 --- a/libs/@local/graph/store/benches/embedding.rs +++ b/libs/@local/graph/store/benches/embedding.rs @@ -145,14 +145,14 @@ fn bench_nearest4(criterion: &mut Criterion) { // k = 15 exercises the odd-k remainder path. for &(d, k) in &[ - (256, nz!(15)), - (256, nz!(16)), - (256, nz!(64)), - (1536, nz!(16)), - (3072, nz!(16)), + (nz!(256), nz!(15)), + (nz!(256), nz!(16)), + (nz!(256), nz!(64)), + (nz!(1536), nz!(16)), + (nz!(3072), nz!(16)), ] { - let points: Vec> = (0..4).map(|seed| random_vec(d, 30 + seed)).collect(); - let centroids = random_vec(k.get() * d, 40); + let points: Vec> = (0..4).map(|seed| random_vec(d.get(), 30 + seed)).collect(); + let centroids = random_vec(k.get() * d.get(), 40); group.bench_with_input( BenchmarkId::new(format!("d{d}"), k), diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index 5db1bcb979b..3b92fb711cf 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -201,25 +201,25 @@ pub(crate) unsafe fn nearest_centroid( point_inv_norm: f32, centroids: &[f32], k: NonZero, - d: usize, + d: NonZero, ) -> (u16, f32) { - debug_assert_eq!(point.len(), d); - debug_assert_eq!(centroids.len(), k.get() * d); + debug_assert_eq!(point.len(), d.get()); + debug_assert_eq!(centroids.len(), k.get() * d.get()); // SAFETY: the caller guarantees these preconditions. The hints let the // compiler elide bounds checks on the centroid slicing inside the loop. unsafe { - core::hint::assert_unchecked(point.len() == d); - core::hint::assert_unchecked(centroids.len() == k.get() * d); - core::hint::assert_unchecked(d.is_multiple_of(8)); + core::hint::assert_unchecked(point.len() == d.get()); + core::hint::assert_unchecked(centroids.len() == k.get() * d.get()); + core::hint::assert_unchecked(d.get().is_multiple_of(8)); } let mut best = 0; let mut best_dot = f32::NEG_INFINITY; for cluster in 0..k.get() { - let start = cluster * d; - let centroid = ¢roids[start..start + d]; + let start = cluster * d.get(); + let centroid = ¢roids[start..start + d.get()]; // SAFETY: `point` and `centroid` both have length `D`, and `D` is a // multiple of 8 (guaranteed by Dimension). @@ -251,7 +251,7 @@ pub(crate) unsafe fn nearest_centroid( /// * `d` is a multiple of 8 unsafe fn lloyd_assign( k: NonZero, - d: usize, + d: NonZero, centroids: &[f32], points: &[f32], inv_norms: &[f32], @@ -263,18 +263,18 @@ unsafe fn lloyd_assign( // SAFETY: the caller guarantees the length relations; the hints let the // compiler elide bounds checks in the tiled loop below. unsafe { - core::hint::assert_unchecked(points.len() == count * d); + core::hint::assert_unchecked(points.len() == count * d.get()); core::hint::assert_unchecked(inv_norms.len() == count); core::hint::assert_unchecked(distances.len() == count); - core::hint::assert_unchecked(d.is_multiple_of(8)); + core::hint::assert_unchecked(d.get().is_multiple_of(8)); } let mut i = 0; while i + 4 <= count { - let p0 = &points[i * d..i * d + d]; - let p1 = &points[(i + 1) * d..(i + 1) * d + d]; - let p2 = &points[(i + 2) * d..(i + 2) * d + d]; - let p3 = &points[(i + 3) * d..(i + 3) * d + d]; + let p0 = &points[i * d.get()..i * d.get() + d.get()]; + let p1 = &points[(i + 1) * d.get()..(i + 1) * d.get() + d.get()]; + let p2 = &points[(i + 2) * d.get()..(i + 2) * d.get() + d.get()]; + let p3 = &points[(i + 3) * d.get()..(i + 3) * d.get() + d.get()]; // SAFETY: each point length d, centroids length k*d, // k > 0, d a multiple of 8 (guaranteed by Dimension). @@ -289,7 +289,7 @@ unsafe fn lloyd_assign( } while i < count { - let point = &points[i * d..i * d + d]; + let point = &points[i * d.get()..i * d.get() + d.get()]; // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. let (label, distance) = unsafe { nearest_centroid(point, inv_norms[i], centroids, k, d) }; @@ -306,7 +306,7 @@ unsafe fn lloyd_assign( struct Restart { k: NonZero, m: usize, - d: usize, + d: NonZero, /// Centroids for this restart, `k * d` elements. centroids: Box<[f32]>, @@ -320,16 +320,24 @@ struct Restart { point_distances: Box<[f32]>, /// Tracks which sample points have been selected as seeds. selected: Box<[bool]>, + /// Point indices grouped by cluster, `m` elements; grouping scratch for + /// [`accumulate_clusters`]. + order: Box<[usize]>, + /// Bucket cursors/boundaries, `k + 1` elements; grouping scratch for + /// [`accumulate_clusters`]. + bounds: Box<[usize]>, } impl Restart { - fn new(k: NonZero, m: usize, d: usize) -> Self { - let centroids: Box<[f32]> = vec![0.0; k.get() * d].into_boxed_slice(); - let sums: Box<[f32]> = vec![0.0; k.get() * d].into_boxed_slice(); + fn new(k: NonZero, m: usize, d: NonZero) -> Self { + let centroids: Box<[f32]> = vec![0.0; k.get() * d.get()].into_boxed_slice(); + let sums: Box<[f32]> = vec![0.0; k.get() * d.get()].into_boxed_slice(); let counts: Box<[usize]> = vec![0; k.get()].into_boxed_slice(); let labels: Box<[u16]> = vec![0; m].into_boxed_slice(); let point_distances: Box<[f32]> = vec![0.0; m].into_boxed_slice(); let selected: Box<[bool]> = vec![false; m].into_boxed_slice(); + let order: Box<[usize]> = vec![0; m].into_boxed_slice(); + let bounds: Box<[usize]> = vec![0; k.get() + 1].into_boxed_slice(); Self { k, @@ -341,6 +349,8 @@ impl Restart { labels, point_distances, selected, + order, + bounds, } } @@ -374,15 +384,15 @@ impl Restart { let mut point = rng.random_range(0..m); for cluster in 0..k.get() { - let centroid_start = cluster * d; - let point_start = point * d; + let centroid_start = cluster * d.get(); + let point_start = point * d.get(); - self.centroids[centroid_start..centroid_start + d] - .copy_from_slice(&sample[point_start..point_start + d]); + self.centroids[centroid_start..centroid_start + d.get()] + .copy_from_slice(&sample[point_start..point_start + d.get()]); // SAFETY: centroid rows have length `d`, and `d` is a multiple of 8. unsafe { - kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d.get()]); } self.selected[point] = true; @@ -393,12 +403,12 @@ impl Restart { break; } - let centroid = &self.centroids[centroid_start..centroid_start + d]; + let centroid = &self.centroids[centroid_start..centroid_start + d.get()]; // Per-element writes only, so the pass is deterministic under // rayon; the D² total is summed sequentially below. sample - .par_chunks_exact(d) + .par_chunks_exact(d.get()) .zip(sample_inv_norms.par_iter()) .zip(self.point_distances.par_iter_mut()) .enumerate() @@ -503,16 +513,22 @@ impl Restart { inertia = self.point_distances.iter().sum(); - // SAFETY: `sample.len() == m * d` with `m` labels, sums is - // `k * d` with `k` counts, and `d` is a multiple of 8 - // (guaranteed by Dimension). + let mut scratch = Scratch { + sums: &mut self.sums, + counts: &mut self.counts, + order: &mut self.order, + bounds: &mut self.bounds, + }; + + // SAFETY: `d` is a multiple of 8 (guaranteed by Dimension); + // every other requirement is checked by `accumulate_clusters` + // itself and panics rather than misbehaving. unsafe { accumulate_clusters( sample, &self.labels, Some(sample_inv_norms), - &mut self.sums, - &mut self.counts, + &mut scratch, d, ); } @@ -522,16 +538,17 @@ impl Restart { continue; } - let start = cluster * d; + let start = cluster * d.get(); // Normalization is scale-invariant, so the raw sum gives the same direction as the // average. - self.centroids[start..start + d].copy_from_slice(&self.sums[start..start + d]); + self.centroids[start..start + d.get()] + .copy_from_slice(&self.sums[start..start + d.get()]); // SAFETY: centroid rows have length `d`, and `d` is a multiple of 8 // (guaranteed by Dimension). unsafe { - kernel::normalize(&mut self.centroids[start..start + d]); + kernel::normalize(&mut self.centroids[start..start + d.get()]); } } @@ -586,15 +603,15 @@ impl Restart { } } - let point_start = farthest_idx * d; - let centroid_start = cluster * d; + let point_start = farthest_idx * d.get(); + let centroid_start = cluster * d.get(); - self.centroids[centroid_start..centroid_start + d] - .copy_from_slice(&sample[point_start..point_start + d]); + self.centroids[centroid_start..centroid_start + d.get()] + .copy_from_slice(&sample[point_start..point_start + d.get()]); // SAFETY: centroid rows have length `d`, a multiple of 8. unsafe { - kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d]); + kernel::normalize(&mut self.centroids[centroid_start..centroid_start + d.get()]); } self.labels[farthest_idx] = cluster as u16; @@ -605,53 +622,126 @@ impl Restart { } } +/// Borrowed scratch for [`accumulate_clusters`]. +struct Scratch<'ctx> { + /// Per-cluster accumulator, `k * d` elements. + sums: &'ctx mut [f32], + /// Per-cluster point count, `k` elements. + counts: &'ctx mut [usize], + /// Point indices grouped by cluster, one per labeled point. + order: &'ctx mut [usize], + /// Bucket cursors during the scatter, bucket boundaries after; + /// `k + 1` elements. + bounds: &'ctx mut [usize], +} + /// Recomputes per-cluster sums and counts from labeled points. /// -/// The result is impervious to any thread schedule order. +/// Points are grouped by cluster first (a stable counting sort on labels, +/// through `order` and `bounds`), then each cluster task walks only its own +/// bucket: one pass over the labels instead of one per cluster. Buckets +/// preserve ascending point order, so every cluster sum receives its +/// additions in a fixed order and the result is impervious to any thread +/// schedule order: the grouping runs sequentially on the calling thread, +/// and each sum is reduced by exactly one task over a fixed range. /// /// `inv_norms` supplies precomputed inverse norms; pass `None` to compute /// them on the fly. /// /// Zero-norm points are counted but contribute nothing to the sums. /// +/// `order` and `bounds` are grouping scratch; their contents on entry are +/// irrelevant. +/// +/// # Panics +/// +/// Panics if any label is not below `counts.len()`, or if the scratch and +/// input shapes are inconsistent: `order.len() != labels.len()`, +/// `bounds.len() != counts.len() + 1`, `sums.len() != counts.len() * d`, +/// or `inv_norms` (when provided) not one entry per label. +/// /// # Safety /// -/// * `points.len() == labels.len() * d` -/// * `sums.len() == counts.len() * d` -/// * `inv_norms`, when provided, has one entry per label -/// * `d` is a multiple of 8 +/// * `d` is a multiple of 8 (the SIMD kernels rely on it; every other requirement is checked at +/// runtime and panics) unsafe fn accumulate_clusters( points: &[f32], labels: &[u16], inv_norms: Option<&[f32]>, - sums: &mut [f32], - counts: &mut [usize], - d: usize, + Scratch { + sums, + counts, + order, + bounds, + }: &mut Scratch<'_>, + d: NonZero, ) { - // A `debug_assert` only: an `assert_unchecked` here would be sound (the - // length is a documented precondition), but to elide the - // `inv_norms[index]` bounds check the fact would have to survive into - // the rayon closure, and that check is noise next to the `d`-wide - // kernel call it precedes. - debug_assert!(inv_norms.is_none_or(|norms| norms.len() == labels.len())); + let d = d.get(); + + // We deliberately opt into checked indexing here. Profiling via performance counters on + // Apple M5 (which does have a large out-of-order execution window) showed that the fully + // checked version has negligible cost: an additional ~13 instructions and ~6 branches per + // point (+5.3% instructions), yet no increase in cycle count. This is because the check + // branches are >99.8% predicted, and the extra µops retire from issue slots that otherwise + // sit idle behind FMA and load latency (backend stall slots *drop* ~4%); the loop is + // memory/FMA-bound, not issue-bound. + // + // While the measurements are specific to Apple M5, the general trend should be transferable + // to other architectures. + // + // The checks buy panics-instead-of-UB for free, shrinking `# Safety` to the kernels' + // alignment requirement. Do not switch this back to unchecked indexing without new + // measurements. + assert!(inv_norms.is_none_or(|norms| norms.len() == labels.len())); + assert_eq!(order.len(), labels.len()); + assert_eq!(bounds.len(), counts.len() + 1); + assert!(!bounds.is_empty()); + assert_eq!(sums.len(), counts.len() * d); + + // 1. Histogram: the counts double as the bucket sizes, and the checked indexing doubles as + // validation: an out-of-range label panics here, before any scratch is written. + counts.fill(0); + for &label in labels { + counts[usize::from(label)] += 1; + } - // TODO(PERF): This isn't as performant as it could be, in theory we could first create a - // histogram, and then use that to dispatch over it. A potential optimization opportunity if - // ever required. + // 2. Bucket starts. During the scatter, `bounds[c + 1]` is cluster `c`'s write cursor; each + // cursor ends at its bucket end, leaving `bounds` as exactly the boundary array the gather + // needs: cluster `c` owns `order[bounds[c]..bounds[c + 1]]`. + bounds[0] = 0; + let mut running = 0; + for (bound, &count) in bounds[1..].iter_mut().zip(counts.iter()) { + *bound = running; + running += count; + } + + // 3. Stable scatter: visiting points in ascending index order keeps each bucket ascending, + // which pins the per-cluster addition order (and therefore the sums) regardless of how rayon + // schedules the gather. + for (index, &label) in labels.iter().enumerate() { + // Cluster `c`'s cursor starts at its bucket start and is bumped once + // per point labeled `c`, of which the histogram counted exactly + // `counts[c]`, so it stays below the bucket end (at most + // `order.len()`): for histogram-validated labels these accesses + // cannot panic. + let cursor = &mut bounds[usize::from(label) + 1]; + order[*cursor] = index; + *cursor += 1; + } + // Shared views for the parallel gather. + let order: &[usize] = order; + let bounds: &[usize] = bounds; + + // 4. Accumulate: one task per cluster, walking only its own bucket. sums.par_chunks_exact_mut(d) - .zip(counts.par_iter_mut()) - .enumerate() - .for_each(|(cluster, (sum, count))| { + .zip(bounds.par_array_windows::<2>()) + .for_each(|(sum, &[start, end])| { sum.fill(0.0); - *count = 0; - for (index, (point, &label)) in points.chunks_exact(d).zip(labels).enumerate() { - if usize::from(label) != cluster { - continue; - } - - *count += 1; + for &index in &order[start..end] { + let row = index * d; + let point = &points[row..row + d]; let inv_norm = inv_norms.map_or_else( || { @@ -686,18 +776,21 @@ unsafe fn accumulate_clusters( /// * `d` is a multiple of 8 unsafe fn label_chunk( centroids: &[f32], - k: NonZero, - d: usize, + k_nz: NonZero, + d_nz: NonZero, points: &[f32], labels: &mut [u16], ) { + let k = k_nz.get(); + let d = d_nz.get(); + let count = labels.len(); // SAFETY: each parallel chunk pairs `count` labels with `count * d` // floats of point data; `d` is a multiple of 8 (guaranteed by Dimension). unsafe { core::hint::assert_unchecked(points.len() == count * d); - core::hint::assert_unchecked(centroids.len() >= k.get() * d); + core::hint::assert_unchecked(centroids.len() >= k * d); core::hint::assert_unchecked(d.is_multiple_of(8)); } @@ -710,7 +803,7 @@ unsafe fn label_chunk( // SAFETY: each point length d, centroids length k*d, k > 0, // d a multiple of 8 (guaranteed by Dimension). - let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k_nz, d_nz) }; labels[i] = nearest[0].0; labels[i + 1] = nearest[1].0; @@ -722,7 +815,7 @@ unsafe fn label_chunk( while i < count { let point = &points[i * d..i * d + d]; // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. - let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k, d) }; + let (label, _) = unsafe { nearest_centroid(point, 1.0, centroids, k_nz, d_nz) }; labels[i] = label; i += 1; } @@ -739,10 +832,12 @@ unsafe fn label_chunk( unsafe fn score_chunk( centroids: &[f32], k: NonZero, - d: usize, + d_nz: NonZero, points: &[f32], labels: &mut [u16], ) -> f32 { + let d = d_nz.get(); + debug_assert_eq!(points.len(), labels.len() * d); // SAFETY: The caller must ensure `points.len() == labels.len() * d`. @@ -762,7 +857,7 @@ unsafe fn score_chunk( // SAFETY: each point length d, centroids length k*d, k > 0, // d a multiple of 8 (guaranteed by Dimension). - let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d) }; + let nearest = unsafe { kernel::nearest4(p0, p1, p2, p3, centroids, k, d_nz) }; let ps = [p0, p1, p2, p3]; for offset in 0..4 { @@ -784,7 +879,7 @@ unsafe fn score_chunk( let inv_norm = if norm > 0.0 { norm.recip() } else { 0.0 }; // SAFETY: point length d, centroids length k*d, k > 0, d mult of 8. - let (label, distance) = unsafe { nearest_centroid(point, inv_norm, centroids, k, d) }; + let (label, distance) = unsafe { nearest_centroid(point, inv_norm, centroids, k, d_nz) }; labels[i] = label; inertia += distance; i += 1; @@ -806,7 +901,7 @@ unsafe fn reassign( centroids: &[f32], labels: &mut [u16], k: NonZero, - d: usize, + d: NonZero, chunk: usize, row_chunk: usize, ) { @@ -839,7 +934,7 @@ unsafe fn reassign_scored( centroids: &[f32], labels: &mut [u16], k: NonZero, - d: usize, + d: NonZero, chunk: usize, row_chunk: usize, ) -> f32 { @@ -861,15 +956,14 @@ unsafe fn reassign_scored( /// from the full population, and re-labels against the final centroids. /// Returns the full-data inertia. /// -/// `sums` and `counts` are accumulator scratch; their contents on entry are -/// irrelevant. +/// `scratch` contents on entry are irrelevant; its shape is validated by +/// [`accumulate_clusters`], which panics on any mismatch. /// /// # Safety /// /// * `x.len() == n * d` for some `n` /// * `clustering.centroids.len() == k * d` /// * `clustering.labels.len() == n` -/// * `sums.len() == k * d` and `counts.len() == k` /// * `d` is a multiple of 8 unsafe fn assign( x: &[f32], @@ -877,10 +971,9 @@ unsafe fn assign( k: NonZero, chunk: usize, row_chunk: usize, - sums: &mut [f32], - counts: &mut [usize], + scratch: &mut Scratch<'_>, ) -> f32 { - let d = clustering.dimension.get() as usize; + let d = NonZero::::from(clustering.dimension.value()); // 1. Label all points against the sample-fitted centroids. // SAFETY: forwarded from the caller. @@ -897,18 +990,19 @@ unsafe fn assign( } // 2. Recompute centroids from the full population. - // SAFETY: `x.len() == n * d` with `n` labels, sums is `k * d` with `k` - // counts, and `d` is a multiple of 8 (guaranteed by Dimension). + // SAFETY: `d` is a multiple of 8 (guaranteed by Dimension); every other + // requirement is checked by `accumulate_clusters` itself and panics + // rather than misbehaving. unsafe { - accumulate_clusters(x, &clustering.labels, None, sums, counts, d); + accumulate_clusters(x, &clustering.labels, None, scratch, d); } - for (cluster, count) in counts.iter_mut().enumerate() { + for (cluster, count) in scratch.counts.iter_mut().enumerate() { if *count == 0 { continue; } - let start = cluster * d; + let start = cluster * d.get(); #[expect( clippy::cast_possible_truncation, @@ -917,7 +1011,7 @@ unsafe fn assign( let centroid = clustering.centroid_mut(cluster as u16); // Normalization is scale-invariant, so the raw sum gives the same // direction as the average. - centroid.copy_from_slice(&sums[start..start + d]); + centroid.copy_from_slice(&scratch.sums[start..start + d.get()]); // SAFETY: centroid length d, a multiple of 8. unsafe { @@ -1028,7 +1122,7 @@ pub fn cluster(x: &[f32], dimension: Dimension, config: &Config) -> Clustering { .into_par_iter() .enumerate() .map(|(index, seed)| { - let mut restart = Restart::new(k, m, d); + let mut restart = Restart::new(k, m, dimension.value().into()); let inertia = restart.run(sample, chunk, row_chunk, &sample_inv_norms, seed, config); (inertia, index, restart) @@ -1038,31 +1132,35 @@ pub fn cluster(x: &[f32], dimension: Dimension, config: &Config) -> Clustering { // Reuse the winning restart's buffers: its centroids become the result // and its per-cluster accumulators serve the full-data recomputation, - // instead of allocating fresh ones. + // instead of allocating fresh ones. Only `order` needs a fresh, `n`-sized + // allocation (the restart's is sample-sized); this is the sole per-fit + // scratch allocation outside setup, and none happen per iteration. let Restart { centroids, mut sums, mut counts, + mut bounds, .. } = best.2; clustering.centroids = centroids; + let mut order = vec![0_usize; n]; + + let mut scratch = Scratch { + sums: &mut sums, + counts: &mut counts, + order: &mut order, + bounds: &mut bounds, + }; + // 3. assign points to clusters // SAFETY: `x.len() == n * d` (asserted above), `clustering.centroids.len() == k * d`, // `sums` and `counts` are the restart's `k * d` and `k` sized accumulators, - // and `d` is a multiple of 8 (guaranteed by Dimension). - clustering.inertia = unsafe { - assign( - x, - &mut clustering, - k, - chunk, - row_chunk, - &mut sums, - &mut counts, - ) - }; + // `order` was just allocated with `n` entries, `bounds` is the restart's + // `k + 1` boundary scratch, and `d` is a multiple of 8 (guaranteed by + // Dimension). + clustering.inertia = unsafe { assign(x, &mut clustering, k, chunk, row_chunk, &mut scratch) }; clustering } @@ -1565,7 +1663,7 @@ mod tests { // SAFETY: point has length D=64, centroids has length k*D, // k > 0, D is a multiple of 8. - let (got, _) = unsafe { nearest_centroid(&p, inv, ¢roids, k, D) }; + let (got, _) = unsafe { nearest_centroid(&p, inv, ¢roids, k, nz!(D)) }; assert_eq!( got, brute_nearest_cosine(&p, ¢roids, k), @@ -1587,9 +1685,9 @@ mod tests { // SAFETY: point has length D=64, centroids has length k*D, // k > 0, D is a multiple of 8. - let (a, _) = unsafe { nearest_centroid(&p, 1.0, ¢roids, k, D) }; + let (a, _) = unsafe { nearest_centroid(&p, 1.0, ¢roids, k, nz!(D)) }; // SAFETY: same preconditions. - let (b, _) = unsafe { nearest_centroid(&p, 0.123, ¢roids, k, D) }; + let (b, _) = unsafe { nearest_centroid(&p, 0.123, ¢roids, k, nz!(D)) }; assert_eq!(a, b, "inv_norm must not change the selected centroid"); } } @@ -1614,4 +1712,139 @@ mod tests { assert!(result.centroids.iter().all(|v| v.is_finite())); assert!(result.labels.iter().all(|&l| l < 5)); } + + /// Sequential reference for [`accumulate_clusters`]: one pass over the + /// points in ascending index order, adding each to its cluster's sum + /// with the same kernels. Bit-identical to the parallel version by + /// construction if (and only if) the grouping preserves per-cluster + /// ascending order, which is exactly the property under test. + fn accumulate_reference( + points: &[f32], + labels: &[u16], + inv_norms: Option<&[f32]>, + k: usize, + d: usize, + ) -> (Vec, Vec) { + let mut sums = vec![0.0_f32; k * d]; + let mut counts = vec![0_usize; k]; + + for (index, (point, &label)) in points.chunks_exact(d).zip(labels).enumerate() { + let cluster = usize::from(label); + counts[cluster] += 1; + + let inv_norm = inv_norms.map_or_else( + || { + // SAFETY: `point` has length `d`, a multiple of 8. + let norm = unsafe { kernel::dot(point, point) }.sqrt(); + + if norm > 0.0 { norm.recip() } else { 0.0 } + }, + |inv_norms| inv_norms[index], + ); + + if inv_norm == 0.0 { + continue; + } + + // SAFETY: sum rows and `point` have length `d`, a multiple of 8. + unsafe { + kernel::add_scaled_into(&mut sums[cluster * d..(cluster + 1) * d], point, inv_norm); + } + } + + (sums, counts) + } + + #[test] + fn accumulate_clusters_matches_sequential_reference_bitwise() { + // Half the points land in cluster 0 (skew), the rest spread over + // 1..=5; cluster 6 stays empty. + const PATTERN: [u16; 10] = [0, 1, 0, 2, 0, 3, 0, 4, 0, 5]; + let n = 203; + let d = 32; + let k = 7_usize; + + let mut rng = Xoshiro256PlusPlus::seed_from_u64(31); + let mut points = vec![0.0_f32; n * d]; + for (i, row) in points.chunks_exact_mut(d).enumerate() { + if i % 17 == 0 { + continue; // leave all-zero: counted, but contributes nothing + } + for v in row.iter_mut() { + *v = rng.random_range(-1.0..1.0); + } + } + + let labels: Vec = (0..n).map(|i| PATTERN[i % PATTERN.len()]).collect(); + + let inv_norms: Vec = points + .chunks_exact(d) + .map(|row| { + let norm = l2(row); + if norm > 0.0 { norm.recip() } else { 0.0 } + }) + .collect(); + + for inv in [None, Some(inv_norms.as_slice())] { + // Sentinels: the accumulator must overwrite all of its outputs. + let mut sums = vec![f32::NAN; k * d]; + let mut counts = vec![usize::MAX; k]; + let mut order = vec![usize::MAX; n]; + let mut bounds = vec![usize::MAX; k + 1]; + + let mut scratch = Scratch { + sums: &mut sums, + counts: &mut counts, + order: &mut order, + bounds: &mut bounds, + }; + + // SAFETY: `points` is `n * d` floats with `n` labels, all labels + // are drawn from `PATTERN` and below `k == 7`, sums are `k * d` + // with `k` counts, inv norms (when provided) have one entry per + // label, order has `n` entries, bounds has `k + 1`, and + // `d == 32` is a multiple of 8. + unsafe { + accumulate_clusters(&points, &labels, inv, &mut scratch, nz!(32)); + } + + let (expected_sums, expected_counts) = + accumulate_reference(&points, &labels, inv, k, d); + + assert_eq!(counts, expected_counts); + assert_eq!(counts[6], 0, "cluster 6 must stay empty"); + assert_eq!(counts.iter().sum::(), n, "every point counted"); + + for (i, (sum, expected)) in sums.iter().zip(&expected_sums).enumerate() { + assert_eq!( + sum.to_bits(), + expected.to_bits(), + "sums diverge at element {i}: {sum} vs {expected}" + ); + } + } + } + + /// The determinism contract: identical bits regardless of pool width. + /// Guards the accumulate/assignment structure against rewrites whose + /// float reduction order depends on rayon's scheduling. + #[test] + fn cluster_bitwise_identical_across_pool_sizes() { + let (data, _) = make_blobs::<8>(40, 4, 2024); + let config = Config::for_k_with_seed(4, 7); + + let single = rayon::ThreadPoolBuilder::new() + .num_threads(1) + .build() + .expect("single-thread pool should build") + .install(|| cluster(&data, dim(8), &config)); + let multi = rayon::ThreadPoolBuilder::new() + .num_threads(8) + .build() + .expect("multi-thread pool should build") + .install(|| cluster(&data, dim(8), &config)); + + assert_eq!(single.labels, multi.labels); + assert_eq!(single.centroids, multi.centroids); + } } diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/store/src/embedding/kernel.rs index 67f99a66831..f1881eb0ab9 100644 --- a/libs/@local/graph/store/src/embedding/kernel.rs +++ b/libs/@local/graph/store/src/embedding/kernel.rs @@ -345,8 +345,11 @@ pub unsafe fn nearest4( p3: &[f32], centroids: &[f32], k: NonZero, - d: usize, + d: NonZero, ) -> [(u16, f32); 4] { + let d = d.get(); + let k = k.get(); + let mut best_dot = [f32::NEG_INFINITY; 4]; let mut best_idx = [0_u16; 4]; @@ -356,12 +359,12 @@ pub unsafe fn nearest4( core::hint::assert_unchecked(p0.len() == p1.len()); core::hint::assert_unchecked(p0.len() == p2.len()); core::hint::assert_unchecked(p0.len() == p3.len()); - core::hint::assert_unchecked(centroids.len() >= k.get() * d); + core::hint::assert_unchecked(centroids.len() >= k * d); core::hint::assert_unchecked(d.is_multiple_of(8)); } let mut j = 0; - while j + 2 <= k.get() { + while j + 2 <= k { // SAFETY: `j + 2 <= k` and `centroids.len() >= k * d`, so both // slices `[j*d .. (j+2)*d]` are in-bounds. let c0 = unsafe { centroids.get_unchecked(j * d..j * d + d) }; @@ -389,7 +392,7 @@ pub unsafe fn nearest4( } // Handle odd k: one remaining centroid via the 4x1 tile. - if j < k.get() { + if j < k { let c = ¢roids[j * d..j * d + d]; // SAFETY: all five slices have length `d`, a multiple of 8. let dots = unsafe { micro_4x1(p0, p1, p2, p3, c) }; @@ -719,7 +722,13 @@ mod tests { // all point slices have length d. let got = unsafe { nearest4( - &points[0], &points[1], &points[2], &points[3], ¢roids, k, d, + &points[0], + &points[1], + &points[2], + &points[3], + ¢roids, + k, + nz!(8), ) }; @@ -749,7 +758,13 @@ mod tests { // all point slices have length d. let got = unsafe { nearest4( - &points[0], &points[1], &points[2], &points[3], ¢roids, k, d, + &points[0], + &points[1], + &points[2], + &points[3], + ¢roids, + k, + nz!(8), ) }; @@ -770,7 +785,7 @@ mod tests { // SAFETY: d=8 (multiple of 8), k=1 > 0, centroids has length d, // all point slices have length d. - let got = unsafe { nearest4(&p0, &p1, &p2, &p3, ¢roids, nz!(1), d) }; + let got = unsafe { nearest4(&p0, &p1, &p2, &p3, ¢roids, nz!(1), nz!(8)) }; assert_eq!(got[0].0, 0); assert_eq!(got[1].0, 0); From 0138056066f9be3d7e6e436e816cb37c4df5fb9b Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:31:25 +0200 Subject: [PATCH 24/78] chore: docs --- libs/@local/graph/store/src/embedding/clustering.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/store/src/embedding/clustering.rs index 3b92fb711cf..8b3d011a483 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/store/src/embedding/clustering.rs @@ -662,8 +662,7 @@ struct Scratch<'ctx> { /// /// # Safety /// -/// * `d` is a multiple of 8 (the SIMD kernels rely on it; every other requirement is checked at -/// runtime and panics) +/// * `d` is a multiple of 8 unsafe fn accumulate_clusters( points: &[f32], labels: &[u16], From 2dafd88ca1ea824d8c8afeb70fa4d67c134cae46 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:23:26 +0200 Subject: [PATCH 25/78] feat: move into embeddings crate --- Cargo.lock | 11 +- libs/@local/graph/embeddings/Cargo.toml | 12 ++ .../benches/clustering.rs} | 125 +++++++++--------- .../src}/clustering.rs | 9 ++ .../embedding => embeddings/src}/dimension.rs | 20 ++- .../embedding => embeddings/src}/kernel.rs | 9 ++ libs/@local/graph/embeddings/src/lib.rs | 23 +++- libs/@local/graph/postgres-store/Cargo.toml | 37 +++--- .../store/postgres/knowledge/entity/mod.rs | 6 +- libs/@local/graph/store/Cargo.toml | 15 +-- libs/@local/graph/store/src/embedding/mod.rs | 16 --- libs/@local/graph/store/src/lib.rs | 7 +- 12 files changed, 157 insertions(+), 133 deletions(-) rename libs/@local/graph/{store/benches/embedding.rs => embeddings/benches/clustering.rs} (66%) rename libs/@local/graph/{store/src/embedding => embeddings/src}/clustering.rs (99%) rename libs/@local/graph/{store/src/embedding => embeddings/src}/dimension.rs (79%) rename libs/@local/graph/{store/src/embedding => embeddings/src}/kernel.rs (99%) delete mode 100644 libs/@local/graph/store/src/embedding/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3045f2d5588..c4912b58916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3678,9 +3678,14 @@ dependencies = [ name = "hash-graph-embeddings" version = "0.0.0" dependencies = [ + "codspeed-criterion-compat", + "darwin-kperf-criterion", "derive_more", "error-stack", "hash-graph-types", + "rand 0.10.1", + "rand_xoshiro", + "rayon", "reqwest", "reqwest-middleware", "reqwest-retry", @@ -3771,6 +3776,7 @@ dependencies = [ "futures-sink", "hash-codec", "hash-graph-authorization", + "hash-graph-embeddings", "hash-graph-migrations", "hash-graph-store", "hash-graph-temporal-versioning", @@ -3804,8 +3810,6 @@ name = "hash-graph-store" version = "0.0.0" dependencies = [ "bytes", - "codspeed-criterion-compat", - "darwin-kperf-criterion", "derive-where", "derive_more", "error-stack", @@ -3818,9 +3822,6 @@ dependencies = [ "hash-temporal-client", "insta", "postgres-types", - "rand 0.10.1", - "rand_xoshiro", - "rayon", "serde", "serde_json", "simple-mermaid", diff --git a/libs/@local/graph/embeddings/Cargo.toml b/libs/@local/graph/embeddings/Cargo.toml index 907e2eb5420..d903507bb96 100644 --- a/libs/@local/graph/embeddings/Cargo.toml +++ b/libs/@local/graph/embeddings/Cargo.toml @@ -17,6 +17,9 @@ error-stack = { workspace = true } # Private third-party dependencies derive_more = { workspace = true, features = ["display", "error"] } +rand = { workspace = true } +rand_xoshiro = { workspace = true } +rayon = { workspace = true } reqwest = { workspace = true } reqwest-middleware = { workspace = true, features = ["json"] } reqwest-retry = { workspace = true } @@ -25,5 +28,14 @@ serde = { workspace = true, features = ["derive"] } simple-mermaid = { workspace = true } tracing = { workspace = true } +[dev-dependencies] +codspeed-criterion-compat = { workspace = true } +darwin-kperf-criterion = { workspace = true, features = ["codspeed"] } + +[[bench]] +name = "clustering" +harness = false + + [lints] workspace = true diff --git a/libs/@local/graph/store/benches/embedding.rs b/libs/@local/graph/embeddings/benches/clustering.rs similarity index 66% rename from libs/@local/graph/store/benches/embedding.rs rename to libs/@local/graph/embeddings/benches/clustering.rs index d5dc2268cc3..24658df6723 100644 --- a/libs/@local/graph/store/benches/embedding.rs +++ b/libs/@local/graph/embeddings/benches/clustering.rs @@ -23,33 +23,39 @@ drop-tightening warning originates inside `criterion_group!`" )] -use core::hint::black_box; +use core::{hint::black_box, num::NonZero}; use codspeed_criterion_compat::{ BenchmarkId, Criterion, criterion_group, criterion_main, measurement::Measurement, }; -use hash_graph_store::embedding::{ +use hash_graph_embeddings::{ + D256, D1536, D3072, Dimension, clustering::{Config, cluster}, - dimension::Dimension, kernel, }; -use rand::{RngExt as _, SeedableRng as _}; +use rand::{RngExt as _, SeedableRng as _, distr::Uniform}; use rand_xoshiro::Xoshiro256PlusPlus; +macro_rules! nz { + ($expr:expr) => { + const { ::core::num::NonZero::new($expr).unwrap() } + }; +} + /// Uniform random values in `[-1, 1)`. -fn random_vec(len: usize, seed: u64) -> Vec { - let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); - core::iter::repeat_with(|| rng.random_range(-1.0..1.0)) - .take(len) +fn random_vec(n: usize, seed: u64) -> Vec { + let rng = Xoshiro256PlusPlus::seed_from_u64(seed); + rng.sample_iter(Uniform::new(-1.0, 1.0).expect("uniform range is non-empty")) + .take(n) .collect() } /// Uniform random values in `[0.1, 1)`, guaranteed positive so repeated /// accumulation saturates at infinity instead of producing NaNs. -fn random_positive_vec(len: usize, seed: u64) -> Vec { - let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed); - core::iter::repeat_with(|| rng.random_range(0.1..1.0)) - .take(len) +fn random_positive_vec(n: usize, seed: u64) -> Vec { + let rng = Xoshiro256PlusPlus::seed_from_u64(seed); + rng.sample_iter(Uniform::new(0.1, 1.0).expect("uniform range is non-empty")) + .take(n) .collect() } @@ -72,16 +78,16 @@ fn blobs(points_per_cluster: usize, k: usize, d: usize, seed: u64) -> Vec { data } -const KERNEL_DIMS: &[usize] = &[256, 1536, 3072]; +const KERNEL_DIMS: [Dimension; 3] = [D256, D1536, D3072]; fn bench_dot(criterion: &mut Criterion) { - let mut group = criterion.benchmark_group("embedding/kernel/dot"); + let mut group = criterion.benchmark_group("kernel/dot"); - for &d in KERNEL_DIMS { - let lhs = random_vec(d, 1); - let rhs = random_vec(d, 2); + for dim in KERNEL_DIMS { + let lhs = random_vec(dim.get() as usize, 1); + let rhs = random_vec(dim.get() as usize, 2); - group.bench_with_input(BenchmarkId::from_parameter(d), &d, |bencher, _| { + group.bench_with_input(BenchmarkId::from_parameter(dim), &dim, |bencher, _| { // SAFETY: both slices have length `d`, a multiple of 8. bencher.iter(|| unsafe { kernel::dot(black_box(&lhs), black_box(&rhs)) }); }); @@ -91,13 +97,13 @@ fn bench_dot(criterion: &mut Criterion) { } fn bench_add_scaled_into(criterion: &mut Criterion) { - let mut group = criterion.benchmark_group("embedding/kernel/add_scaled_into"); + let mut group = criterion.benchmark_group("kernel/add_scaled_into"); - for &d in KERNEL_DIMS { - let src = random_positive_vec(d, 3); - let mut dst = random_positive_vec(d, 4); + for dim in KERNEL_DIMS { + let src = random_positive_vec(dim.get() as usize, 3); + let mut dst = random_positive_vec(dim.get() as usize, 4); - group.bench_with_input(BenchmarkId::from_parameter(d), &d, |bencher, _| { + group.bench_with_input(BenchmarkId::from_parameter(dim), &dim, |bencher, _| { // SAFETY: both slices have length `d`, a multiple of 8. bencher.iter(|| unsafe { kernel::add_scaled_into(black_box(&mut dst), black_box(&src), black_box(0.5)); @@ -109,21 +115,22 @@ fn bench_add_scaled_into(criterion: &mut Criterion) { } fn bench_micro_4x2(criterion: &mut Criterion) { - let mut group = criterion.benchmark_group("embedding/kernel/micro_4x2"); + let mut group = criterion.benchmark_group("kernel/micro_4x2"); - for &d in KERNEL_DIMS { - let points: Vec> = (0..4).map(|seed| random_vec(d, 10 + seed)).collect(); - let c0 = random_vec(d, 20); - let c1 = random_vec(d, 21); + for dim in KERNEL_DIMS { + let [p0, p1, p2, p3] = + core::array::from_fn(|index| random_vec(dim.get() as usize, 10 + index as u64)); + let c0 = random_vec(dim.get() as usize, 20); + let c1 = random_vec(dim.get() as usize, 21); - group.bench_with_input(BenchmarkId::from_parameter(d), &d, |bencher, _| { + group.bench_with_input(BenchmarkId::from_parameter(dim), &dim, |bencher, _| { // SAFETY: all six slices have length `d`, a multiple of 8. bencher.iter(|| unsafe { kernel::micro_4x2( - black_box(&points[0]), - black_box(&points[1]), - black_box(&points[2]), - black_box(&points[3]), + black_box(&p0), + black_box(&p1), + black_box(&p2), + black_box(&p3), black_box(&c0), black_box(&c1), ) @@ -134,41 +141,36 @@ fn bench_micro_4x2(criterion: &mut Criterion) { group.finish(); } -macro_rules! nz { - ($expr:expr) => { - const { ::core::num::NonZero::new($expr).unwrap() } - }; -} - fn bench_nearest4(criterion: &mut Criterion) { - let mut group = criterion.benchmark_group("embedding/kernel/nearest4"); + let mut group = criterion.benchmark_group("kernel/nearest4"); // k = 15 exercises the odd-k remainder path. - for &(d, k) in &[ - (nz!(256), nz!(15)), - (nz!(256), nz!(16)), - (nz!(256), nz!(64)), - (nz!(1536), nz!(16)), - (nz!(3072), nz!(16)), + for &(dim, k) in &[ + (D256, nz!(15)), + (D256, nz!(16)), + (D256, nz!(64)), + (D1536, nz!(16)), + (D3072, nz!(16)), ] { - let points: Vec> = (0..4).map(|seed| random_vec(d.get(), 30 + seed)).collect(); - let centroids = random_vec(k.get() * d.get(), 40); + let [p0, p1, p2, p3] = + core::array::from_fn(|index| random_vec(dim.get() as usize, 30 + index as u64)); + let centroids = random_vec(k.get() * dim.get() as usize, 40); group.bench_with_input( - BenchmarkId::new(format!("d{d}"), k), - &(d, k), + BenchmarkId::new(format!("d{dim}"), k), + &(dim, k), |bencher, _| { // SAFETY: point slices have length `d` (multiple of 8), // centroids has length `k * d`, and `k > 0`. bencher.iter(|| unsafe { kernel::nearest4( - black_box(&points[0]), - black_box(&points[1]), - black_box(&points[2]), - black_box(&points[3]), + black_box(&p0), + black_box(&p1), + black_box(&p2), + black_box(&p3), black_box(¢roids), black_box(k), - black_box(d), + black_box(NonZero::from(dim.value())), ) }); }, @@ -179,15 +181,12 @@ fn bench_nearest4(criterion: &mut Criterion) { } fn bench_cluster(criterion: &mut Criterion) { - rayon::ThreadPoolBuilder::new() + let pool = rayon::ThreadPoolBuilder::new() .num_threads(1) - .build_global() + .build() .expect("should be built exactly once"); - let mut group = criterion.benchmark_group("embedding/cluster"); - group.sample_size(10); - - let dimension = Dimension::new(256).expect("256 is a positive multiple of 8"); + let mut group = criterion.benchmark_group("cluster"); // (n, k): n = 10k exercises the subsampled fit (m = 8192) plus the // full-data refinement; n = 50k shifts the weight onto the full-data @@ -205,7 +204,9 @@ fn bench_cluster(criterion: &mut Criterion) { BenchmarkId::new(format!("n{n}_d256"), k), &(n, k), |bencher, _| { - bencher.iter(|| cluster(black_box(&data), black_box(dimension), &config)); + pool.install(|| { + bencher.iter(|| cluster(black_box(&data), black_box(D256), &config)); + }); }, ); } diff --git a/libs/@local/graph/store/src/embedding/clustering.rs b/libs/@local/graph/embeddings/src/clustering.rs similarity index 99% rename from libs/@local/graph/store/src/embedding/clustering.rs rename to libs/@local/graph/embeddings/src/clustering.rs index 8b3d011a483..45111c4b8f6 100644 --- a/libs/@local/graph/store/src/embedding/clustering.rs +++ b/libs/@local/graph/embeddings/src/clustering.rs @@ -1,3 +1,12 @@ +#![expect( + unsafe_code, + clippy::indexing_slicing, + clippy::float_arithmetic, + clippy::min_ident_chars, + clippy::many_single_char_names, + reason = "Single-char idents (k, n, m, d, x) are standard mathematical notation for \ + clustering." +)] use alloc::borrow::Cow; use core::{cmp, num::NonZero}; use std::collections::HashSet; diff --git a/libs/@local/graph/store/src/embedding/dimension.rs b/libs/@local/graph/embeddings/src/dimension.rs similarity index 79% rename from libs/@local/graph/store/src/embedding/dimension.rs rename to libs/@local/graph/embeddings/src/dimension.rs index 0ba85516ee3..3701ee85a91 100644 --- a/libs/@local/graph/store/src/embedding/dimension.rs +++ b/libs/@local/graph/embeddings/src/dimension.rs @@ -1,4 +1,4 @@ -use core::num::NonZero; +use core::{fmt, fmt::Display, num::NonZero}; /// An embedding vector dimension, guaranteed to be a positive multiple of 8. /// @@ -39,6 +39,12 @@ impl Dimension { } } +impl Display for Dimension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.get(), f) + } +} + pub const D128: Dimension = Dimension(NonZero::new(128).unwrap()); pub const D256: Dimension = Dimension(NonZero::new(256).unwrap()); pub const D512: Dimension = Dimension(NonZero::new(512).unwrap()); @@ -51,10 +57,10 @@ mod tests { #[test] fn valid_multiples_of_8() { - for v in [8, 16, 24, 128, 256, 3072] { + for value in [8, 16, 24, 128, 256, 3072] { assert!( - Dimension::new(v).is_some(), - "{v} should be a valid dimension" + Dimension::new(value).is_some(), + "{value} should be a valid dimension" ); } } @@ -66,10 +72,10 @@ mod tests { #[test] fn non_multiples_of_8_rejected() { - for v in [1, 2, 3, 4, 5, 6, 7, 9, 10, 15, 17, 100, 3071] { + for value in [1, 2, 3, 4, 5, 6, 7, 9, 10, 15, 17, 100, 3071] { assert!( - Dimension::new(v).is_none(), - "{v} should not be a valid dimension" + Dimension::new(value).is_none(), + "{value} should not be a valid dimension" ); } } diff --git a/libs/@local/graph/store/src/embedding/kernel.rs b/libs/@local/graph/embeddings/src/kernel.rs similarity index 99% rename from libs/@local/graph/store/src/embedding/kernel.rs rename to libs/@local/graph/embeddings/src/kernel.rs index f1881eb0ab9..13040e483d6 100644 --- a/libs/@local/graph/store/src/embedding/kernel.rs +++ b/libs/@local/graph/embeddings/src/kernel.rs @@ -1,8 +1,17 @@ +#![expect( + unsafe_code, + clippy::indexing_slicing, + clippy::float_arithmetic, + clippy::min_ident_chars, + reason = "Single-char idents (k, n, m, d, x) are standard mathematical notation for \ + clustering." +)] #![expect( clippy::inline_always, reason = "while usually discouraged, SIMD operations need to be inlined, as otherwise we \ spill SIMD registers, see the SIMD documentation." )] + use core::{ num::NonZero, simd::{Simd, f32x8, num::SimdFloat as _}, diff --git a/libs/@local/graph/embeddings/src/lib.rs b/libs/@local/graph/embeddings/src/lib.rs index a5e819a016f..0351f0a0f81 100644 --- a/libs/@local/graph/embeddings/src/lib.rs +++ b/libs/@local/graph/embeddings/src/lib.rs @@ -4,18 +4,33 @@ //! //! ## Workspace dependencies #![cfg_attr(doc, doc = simple_mermaid::mermaid!("../docs/dependency-diagram.mmd"))] +#![feature( + // Library Features + portable_simd, + integer_widen_truncate +)] -pub use self::{ - error::EmbeddingError, - openai::{OpenAiEmbeddingClient, OpenAiEmbeddingClientConfig}, -}; +extern crate alloc; mod error; mod openai; +pub mod clustering; +mod dimension; +// Hidden from docs: the kernel is an implementation detail, exposed only so +// the `embedding` bench target can measure it in isolation. +#[doc(hidden)] +pub mod kernel; + use error_stack::Report; use hash_graph_types::Embedding; +pub use self::{ + dimension::{D128, D256, D512, D1536, D3072, Dimension}, + error::EmbeddingError, + openai::{OpenAiEmbeddingClient, OpenAiEmbeddingClientConfig}, +}; + /// Generates embedding vectors for text inputs. /// /// Implementations call out to an embedding provider (e.g. OpenAI). The generated embeddings are diff --git a/libs/@local/graph/postgres-store/Cargo.toml b/libs/@local/graph/postgres-store/Cargo.toml index a96ddd34dc6..c95229feb08 100644 --- a/libs/@local/graph/postgres-store/Cargo.toml +++ b/libs/@local/graph/postgres-store/Cargo.toml @@ -31,24 +31,25 @@ hash-status = { workspace = true } type-system = { workspace = true, features = ["postgres"] } # Private third-party dependencies -async-scoped = { workspace = true, features = ["use-tokio"] } -bytes = { workspace = true } -clap = { workspace = true, optional = true, features = ["derive", "env"] } -derive-where = { workspace = true } -derive_more = { workspace = true } -dotenv-flow = { workspace = true } -futures = { workspace = true } -postgres-types = { workspace = true, features = ["derive", "with-serde_json-1"] } -refinery = { workspace = true, features = ["tokio-postgres"] } -regex = { workspace = true } -semver = { workspace = true, features = ["serde"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -simple-mermaid = { workspace = true } -time = { workspace = true } -tracing = { workspace = true } -utoipa = { workspace = true, optional = true, features = ["uuid"] } -uuid = { workspace = true, features = ["v4", "serde"] } +async-scoped = { workspace = true, features = ["use-tokio"] } +bytes = { workspace = true } +clap = { workspace = true, optional = true, features = ["derive", "env"] } +derive-where = { workspace = true } +derive_more = { workspace = true } +dotenv-flow = { workspace = true } +futures = { workspace = true } +hash-graph-embeddings.workspace = true +postgres-types = { workspace = true, features = ["derive", "with-serde_json-1"] } +refinery = { workspace = true, features = ["tokio-postgres"] } +regex = { workspace = true } +semver = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +simple-mermaid = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true, optional = true, features = ["uuid"] } +uuid = { workspace = true, features = ["v4", "serde"] } [dev-dependencies] hash-graph-migrations = { workspace = true } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index d69d276cfaf..719c23e8d00 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -17,8 +17,8 @@ use hash_graph_authorization::policies::{ resource::{EntityResourceConstraint, ResourceConstraint}, store::{PolicyCreationParams, PrincipalStore as _}, }; +use hash_graph_embeddings::Dimension; use hash_graph_store::{ - embedding::dimension::Dimension, entity::{ ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, DeletionSummary, EmptyEntityTypes, EntityCluster, EntityPermissions, EntityQueryCursor, @@ -2740,7 +2740,7 @@ where }); } - let config = hash_graph_store::embedding::clustering::Config::for_k_with_seed( + let config = hash_graph_embeddings::clustering::Config::for_k_with_seed( params.cluster_count, params.seed.unwrap_or_else(|| { std::time::SystemTime::UNIX_EPOCH @@ -2757,7 +2757,7 @@ where ); let result = tokio::task::spawn_blocking(move || { - hash_graph_store::embedding::clustering::cluster(&flat, dimension, &config) + hash_graph_embeddings::clustering::cluster(&flat, dimension, &config) }) .await .change_context(ClusterError::Store)?; diff --git a/libs/@local/graph/store/Cargo.toml b/libs/@local/graph/store/Cargo.toml index afd99bc632c..825638c1e35 100644 --- a/libs/@local/graph/store/Cargo.toml +++ b/libs/@local/graph/store/Cargo.toml @@ -29,9 +29,6 @@ bytes = { workspace = true, optional = true } derive-where = { workspace = true } derive_more = { workspace = true, features = ["display", "error"] } futures = { workspace = true } -rand = { workspace = true } -rand_xoshiro = { workspace = true } -rayon = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } simple-mermaid = { workspace = true } @@ -40,20 +37,14 @@ tracing = { workspace = true } uuid = { workspace = true, features = ["v4"] } [dev-dependencies] -codspeed-criterion-compat = { workspace = true } -darwin-kperf-criterion = { workspace = true, features = ["codspeed"] } -hash-codegen = { workspace = true } -insta = { workspace = true } -tokio = { workspace = true, features = ["macros"] } +hash-codegen = { workspace = true } +insta = { workspace = true } +tokio = { workspace = true, features = ["macros"] } [[test]] name = "codegen" required-features = ["codegen"] -[[bench]] -name = "embedding" -harness = false - [features] codegen = ["dep:specta", "type-system/codegen", "hash-graph-authorization/codegen"] utoipa = ["hash-graph-temporal-versioning/utoipa", "type-system/utoipa", "dep:utoipa"] diff --git a/libs/@local/graph/store/src/embedding/mod.rs b/libs/@local/graph/store/src/embedding/mod.rs deleted file mode 100644 index d184500214f..00000000000 --- a/libs/@local/graph/store/src/embedding/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -#![expect( - unsafe_code, - clippy::indexing_slicing, - clippy::float_arithmetic, - clippy::min_ident_chars, - clippy::many_single_char_names, - reason = "Single-char idents (k, n, m, d, x) are standard mathematical notation for \ - clustering." -)] - -pub mod clustering; -pub mod dimension; -// Hidden from docs: the kernel is an implementation detail, exposed only so -// the `embedding` bench target can measure it in isolation. -#[doc(hidden)] -pub mod kernel; diff --git a/libs/@local/graph/store/src/lib.rs b/libs/@local/graph/store/src/lib.rs index 668c46b20b8..15a1b96c52f 100644 --- a/libs/@local/graph/store/src/lib.rs +++ b/libs/@local/graph/store/src/lib.rs @@ -4,11 +4,7 @@ #![cfg_attr(doc, doc = simple_mermaid::mermaid!("../docs/dependency-diagram.mmd"))] #![feature( // Language Features - impl_trait_in_assoc_type, - - // Library Features, - portable_simd, - integer_widen_truncate, + impl_trait_in_assoc_type )] #![cfg_attr(test, feature( // Language Features @@ -27,7 +23,6 @@ pub mod oauth_provider; pub mod property_type; pub mod user_deletion; -pub mod embedding; pub mod error; pub mod filter; pub mod migration; From aff33173f97be4813a58d4d393f67b7976bfb9c0 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:28:19 +0200 Subject: [PATCH 26/78] fix: CI --- libs/@local/graph/embeddings/package.json | 6 +++- libs/@local/graph/postgres-store/Cargo.toml | 38 ++++++++++----------- libs/@local/graph/store/package.json | 3 -- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/libs/@local/graph/embeddings/package.json b/libs/@local/graph/embeddings/package.json index 1ed4d31f77d..15d7db0be60 100644 --- a/libs/@local/graph/embeddings/package.json +++ b/libs/@local/graph/embeddings/package.json @@ -4,9 +4,13 @@ "private": true, "license": "AGPL-3", "scripts": { + "build:codspeed": "cargo codspeed build -p hash-graph-embeddings", "doc:dependency-diagram": "cargo run -p hash-repo-chores -- dependency-diagram --output docs/dependency-diagram.mmd --root hash-graph-embeddings --root-deps-and-dependents --link-mode non-roots --include-dev-deps --include-build-deps --logging-console-level info", "fix:clippy": "just clippy --fix", - "lint:clippy": "just clippy" + "lint:clippy": "just clippy", + "test:codspeed": "cargo codspeed run -p hash-graph-embeddings", + "test:miri": "cargo miri nextest run -- kernel clustering::tests::nearest_centroid_argmax_independent_of_inv_norm", + "test:unit": "mise run test:unit @rust/hash-graph-embeddings" }, "dependencies": { "@rust/error-stack": "workspace:*", diff --git a/libs/@local/graph/postgres-store/Cargo.toml b/libs/@local/graph/postgres-store/Cargo.toml index c95229feb08..114c71642d4 100644 --- a/libs/@local/graph/postgres-store/Cargo.toml +++ b/libs/@local/graph/postgres-store/Cargo.toml @@ -25,31 +25,31 @@ tokio-postgres = { workspace = true, public = true } # Private workspace dependencies error-stack = { workspace = true, features = ["std", "serde", "unstable"] } hash-codec = { workspace = true, features = ["numeric", "postgres"] } +hash-graph-embeddings = { workspace = true } hash-graph-temporal-versioning = { workspace = true, features = ["postgres"] } hash-graph-types = { workspace = true, features = ["postgres"] } hash-status = { workspace = true } type-system = { workspace = true, features = ["postgres"] } # Private third-party dependencies -async-scoped = { workspace = true, features = ["use-tokio"] } -bytes = { workspace = true } -clap = { workspace = true, optional = true, features = ["derive", "env"] } -derive-where = { workspace = true } -derive_more = { workspace = true } -dotenv-flow = { workspace = true } -futures = { workspace = true } -hash-graph-embeddings.workspace = true -postgres-types = { workspace = true, features = ["derive", "with-serde_json-1"] } -refinery = { workspace = true, features = ["tokio-postgres"] } -regex = { workspace = true } -semver = { workspace = true, features = ["serde"] } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -simple-mermaid = { workspace = true } -time = { workspace = true } -tracing = { workspace = true } -utoipa = { workspace = true, optional = true, features = ["uuid"] } -uuid = { workspace = true, features = ["v4", "serde"] } +async-scoped = { workspace = true, features = ["use-tokio"] } +bytes = { workspace = true } +clap = { workspace = true, optional = true, features = ["derive", "env"] } +derive-where = { workspace = true } +derive_more = { workspace = true } +dotenv-flow = { workspace = true } +futures = { workspace = true } +postgres-types = { workspace = true, features = ["derive", "with-serde_json-1"] } +refinery = { workspace = true, features = ["tokio-postgres"] } +regex = { workspace = true } +semver = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +simple-mermaid = { workspace = true } +time = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true, optional = true, features = ["uuid"] } +uuid = { workspace = true, features = ["v4", "serde"] } [dev-dependencies] hash-graph-migrations = { workspace = true } diff --git a/libs/@local/graph/store/package.json b/libs/@local/graph/store/package.json index 2fb5ede493d..04978d129c6 100644 --- a/libs/@local/graph/store/package.json +++ b/libs/@local/graph/store/package.json @@ -15,14 +15,11 @@ "./types": "./types/index.snap.js" }, "scripts": { - "build:codspeed": "cargo codspeed build -p hash-graph-store", "build:types": "INSTA_UPDATE=always mise exec --env dev cargo:cargo-insta -- cargo-insta test --features codegen --test codegen", "doc:dependency-diagram": "cargo run -p hash-repo-chores -- dependency-diagram --output docs/dependency-diagram.mmd --root hash-graph-store --root-deps-and-dependents --link-mode non-roots --include-dev-deps --include-build-deps --logging-console-level info", "fix:clippy": "just clippy --fix", "lint:clippy": "just clippy", "lint:tsc": "tsc --noEmit", - "test:codspeed": "cargo codspeed run -p hash-graph-store", - "test:miri": "cargo miri nextest run -- embedding::kernel embedding::clustering::tests::nearest_centroid_argmax_independent_of_inv_norm", "test:unit": "mise run test:unit @rust/hash-graph-store" }, "dependencies": { From eb38f87d283f629d2a76487c9ca022578133f591 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:29:55 +0200 Subject: [PATCH 27/78] chore: regenerate files --- apps/hash-graph/docs/dependency-diagram.mmd | 4 +- .../rust/docs/dependency-diagram.mmd | 32 ++-- libs/@local/codec/docs/dependency-diagram.mmd | 2 +- .../codegen/docs/dependency-diagram.mmd | 2 +- .../graph/api/docs/dependency-diagram.mmd | 4 +- .../authorization/docs/dependency-diagram.mmd | 32 ++-- .../embeddings/docs/dependency-diagram.mmd | 86 ++++++----- libs/@local/graph/embeddings/package.json | 3 + .../docs/dependency-diagram.mmd | 129 ++++++++-------- libs/@local/graph/postgres-store/package.json | 1 + .../graph/store/docs/dependency-diagram.mmd | 32 ++-- libs/@local/graph/store/package.json | 1 - .../docs/dependency-diagram.mmd | 2 +- .../type-fetcher/docs/dependency-diagram.mmd | 24 +-- .../graph/types/docs/dependency-diagram.mmd | 32 ++-- .../validation/docs/dependency-diagram.mmd | 30 ++-- .../harpc/server/docs/dependency-diagram.mmd | 26 ++-- .../harpc/types/docs/dependency-diagram.mmd | 2 +- .../wire-protocol/docs/dependency-diagram.mmd | 2 +- .../hashql/ast/docs/dependency-diagram.mmd | 141 +++++++++--------- .../compiletest/docs/dependency-diagram.mmd | 141 +++++++++--------- .../hashql/eval/docs/dependency-diagram.mmd | 141 +++++++++--------- .../hashql/hir/docs/dependency-diagram.mmd | 141 +++++++++--------- .../hashql/mir/docs/dependency-diagram.mmd | 141 +++++++++--------- .../syntax-jexpr/docs/dependency-diagram.mmd | 141 +++++++++--------- .../docs/dependency-diagram.mmd | 32 ++-- .../rust/docs/dependency-diagram.mmd | 34 ++--- 27 files changed, 664 insertions(+), 694 deletions(-) diff --git a/apps/hash-graph/docs/dependency-diagram.mmd b/apps/hash-graph/docs/dependency-diagram.mmd index ec8e7669a4d..c5aad87b71e 100644 --- a/apps/hash-graph/docs/dependency-diagram.mmd +++ b/apps/hash-graph/docs/dependency-diagram.mmd @@ -56,7 +56,6 @@ graph TD 1 -.-> 41 2 -.-> 3 2 --> 23 - 4 --> 6 4 --> 12 4 --> 13 4 --> 19 @@ -64,15 +63,16 @@ graph TD 4 --> 32 5 --> 1 6 --> 14 + 6 -.-> 37 7 --> 8 7 --> 34 + 9 --> 6 9 -.-> 7 9 --> 15 9 --> 33 10 --> 5 10 --> 14 10 --> 35 - 10 -.-> 37 11 --> 2 12 --> 33 13 --> 10 diff --git a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd index 3cbfa718cbd..caefa648975 100644 --- a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd +++ b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd @@ -32,40 +32,35 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[darwin-kperf] - 24[darwin-kperf-criterion] - 25[darwin-kperf-events] - 26[darwin-kperf-sys] - 27[error-stack] - 28[hash-graph-benches] - 29[hash-graph-integration] - 30[hash-graph-test-data] + 23[error-stack] + 24[hash-graph-benches] + 25[hash-graph-integration] + 26[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 30 + 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 8 --> 22 - 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 30 - 12 -.-> 30 + 11 -.-> 26 + 12 -.-> 26 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 27 + 15 --> 23 16 -.-> 17 17 --> 18 17 --> 21 @@ -75,9 +70,6 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 23 --> 25 - 23 --> 26 - 24 --> 23 - 28 -.-> 4 - 29 -.-> 7 - 30 --> 8 + 24 -.-> 4 + 25 -.-> 7 + 26 --> 8 diff --git a/libs/@local/codec/docs/dependency-diagram.mmd b/libs/@local/codec/docs/dependency-diagram.mmd index 2dc72bb1081..046b44f0057 100644 --- a/libs/@local/codec/docs/dependency-diagram.mmd +++ b/libs/@local/codec/docs/dependency-diagram.mmd @@ -46,13 +46,13 @@ graph TD 1 -.-> 31 2 -.-> 3 2 --> 19 - 4 --> 6 4 --> 10 4 --> 15 4 --> 23 4 --> 26 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/codegen/docs/dependency-diagram.mmd b/libs/@local/codegen/docs/dependency-diagram.mmd index 46e354a3c6d..08c5bbadc20 100644 --- a/libs/@local/codegen/docs/dependency-diagram.mmd +++ b/libs/@local/codegen/docs/dependency-diagram.mmd @@ -42,13 +42,13 @@ graph TD 1 --> 9 1 -.-> 28 2 -.-> 3 - 4 --> 6 4 --> 10 4 --> 15 4 --> 21 4 --> 24 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/graph/api/docs/dependency-diagram.mmd b/libs/@local/graph/api/docs/dependency-diagram.mmd index ec3530323a2..b15023971bf 100644 --- a/libs/@local/graph/api/docs/dependency-diagram.mmd +++ b/libs/@local/graph/api/docs/dependency-diagram.mmd @@ -57,7 +57,6 @@ graph TD 1 -.-> 42 2 -.-> 3 2 --> 23 - 4 --> 6 4 --> 12 4 --> 13 4 --> 19 @@ -65,15 +64,16 @@ graph TD 4 --> 32 5 --> 1 6 --> 14 + 6 -.-> 37 7 --> 8 7 --> 34 + 9 --> 6 9 -.-> 7 9 --> 15 9 --> 33 10 --> 5 10 --> 14 10 --> 35 - 10 -.-> 37 11 --> 2 12 --> 33 13 --> 10 diff --git a/libs/@local/graph/authorization/docs/dependency-diagram.mmd b/libs/@local/graph/authorization/docs/dependency-diagram.mmd index 002c88588d2..aa9283d70f0 100644 --- a/libs/@local/graph/authorization/docs/dependency-diagram.mmd +++ b/libs/@local/graph/authorization/docs/dependency-diagram.mmd @@ -32,40 +32,35 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[darwin-kperf] - 24[darwin-kperf-criterion] - 25[darwin-kperf-events] - 26[darwin-kperf-sys] - 27[error-stack] - 28[hash-graph-benches] - 29[hash-graph-integration] - 30[hash-graph-test-data] + 23[error-stack] + 24[hash-graph-benches] + 25[hash-graph-integration] + 26[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 30 + 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 8 --> 22 - 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 30 - 12 -.-> 30 + 11 -.-> 26 + 12 -.-> 26 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 27 + 15 --> 23 16 -.-> 17 17 --> 18 17 --> 21 @@ -75,9 +70,6 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 23 --> 25 - 23 --> 26 - 24 --> 23 - 28 -.-> 4 - 29 -.-> 7 - 30 --> 8 + 24 -.-> 4 + 25 -.-> 7 + 26 --> 8 diff --git a/libs/@local/graph/embeddings/docs/dependency-diagram.mmd b/libs/@local/graph/embeddings/docs/dependency-diagram.mmd index 97ab6d97b76..5aa3d1e61bb 100644 --- a/libs/@local/graph/embeddings/docs/dependency-diagram.mmd +++ b/libs/@local/graph/embeddings/docs/dependency-diagram.mmd @@ -16,40 +16,58 @@ graph TD 5[hash-graph-authorization] 6[hash-graph-embeddings] class 6 root - 7[hash-graph-store] - 8[hash-graph-temporal-versioning] - 9[hash-graph-types] - 10[harpc-types] - 11[harpc-wire-protocol] - 12[hash-temporal-client] - 13[darwin-kperf] - 14[darwin-kperf-criterion] - 15[darwin-kperf-events] - 16[darwin-kperf-sys] - 17[error-stack] - 18[hash-graph-benches] - 19[hash-graph-test-data] + 7[hash-graph-postgres-store] + 8[hash-graph-store] + 9[hash-graph-temporal-versioning] + 10[hash-graph-types] + 11[harpc-types] + 12[harpc-wire-protocol] + 13[hashql-ast] + 14[hashql-compiletest] + 15[hashql-eval] + 16[hashql-hir] + 17[hashql-mir] + 18[hashql-syntax-jexpr] + 19[hash-temporal-client] + 20[darwin-kperf] + 21[darwin-kperf-criterion] + 22[darwin-kperf-events] + 23[darwin-kperf-sys] + 24[error-stack] + 25[hash-graph-benches] + 26[hash-graph-integration] + 27[hash-graph-test-data] 0 --> 4 - 1 --> 8 - 1 -.-> 19 + 1 --> 9 + 1 -.-> 27 2 -.-> 3 - 2 --> 11 - 4 --> 6 - 4 --> 7 + 2 --> 12 + 4 --> 15 + 4 --> 18 5 --> 1 - 6 --> 9 - 7 --> 5 - 7 --> 9 - 7 --> 12 - 7 -.-> 14 - 8 --> 2 - 9 -.-> 19 - 11 -.-> 10 - 11 --> 10 - 11 --> 17 - 12 --> 1 - 13 --> 15 - 13 --> 16 - 14 --> 13 - 18 -.-> 4 - 19 --> 7 + 6 --> 10 + 6 -.-> 21 + 7 --> 6 + 8 --> 5 + 8 --> 10 + 8 --> 19 + 9 --> 2 + 10 -.-> 27 + 12 -.-> 11 + 12 --> 11 + 12 --> 24 + 13 -.-> 14 + 14 --> 15 + 14 --> 18 + 15 --> 7 + 15 --> 17 + 16 -.-> 14 + 17 --> 16 + 18 --> 13 + 19 --> 1 + 20 --> 22 + 20 --> 23 + 21 --> 20 + 25 -.-> 4 + 26 -.-> 7 + 27 --> 8 diff --git a/libs/@local/graph/embeddings/package.json b/libs/@local/graph/embeddings/package.json index 15d7db0be60..61d00ad4ee1 100644 --- a/libs/@local/graph/embeddings/package.json +++ b/libs/@local/graph/embeddings/package.json @@ -15,5 +15,8 @@ "dependencies": { "@rust/error-stack": "workspace:*", "@rust/hash-graph-types": "workspace:*" + }, + "devDependencies": { + "@rust/darwin-kperf-criterion": "workspace:*" } } diff --git a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd index d26e8f3bc11..eaa2eff809a 100644 --- a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd @@ -14,69 +14,72 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - class 8 root - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-eval] - 18[hashql-hir] - 19[hashql-mir] - 20[hashql-syntax-jexpr] - 21[hash-status] - 22[hash-telemetry] - 23[hash-temporal-client] - 24[darwin-kperf] - 25[darwin-kperf-criterion] - 26[darwin-kperf-events] - 27[darwin-kperf-sys] - 28[error-stack] - 29[hash-graph-benches] - 30[hash-graph-integration] - 31[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + class 9 root + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-eval] + 19[hashql-hir] + 20[hashql-mir] + 21[hashql-syntax-jexpr] + 22[hash-status] + 23[hash-telemetry] + 24[hash-temporal-client] + 25[darwin-kperf] + 26[darwin-kperf-criterion] + 27[darwin-kperf-events] + 28[darwin-kperf-sys] + 29[error-stack] + 30[hash-graph-benches] + 31[hash-graph-integration] + 32[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 31 + 1 --> 11 + 1 -.-> 32 2 -.-> 3 - 2 --> 14 - 4 --> 17 - 4 --> 20 + 2 --> 15 + 4 --> 18 + 4 --> 21 5 --> 1 - 6 --> 7 - 6 --> 22 - 8 -.-> 6 - 8 --> 12 - 8 --> 21 - 9 --> 5 - 9 --> 11 - 9 --> 23 - 9 -.-> 25 - 10 --> 2 - 11 -.-> 31 - 12 -.-> 31 - 14 -.-> 13 - 14 --> 13 - 14 --> 28 - 15 -.-> 16 - 16 --> 17 - 16 --> 20 - 17 --> 8 - 17 --> 19 - 18 -.-> 16 - 19 --> 18 - 20 --> 15 - 22 --> 28 - 23 --> 1 - 24 --> 26 - 24 --> 27 - 25 --> 24 - 29 -.-> 4 - 30 -.-> 8 - 31 --> 9 + 6 --> 12 + 6 -.-> 26 + 7 --> 8 + 7 --> 23 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 22 + 10 --> 5 + 10 --> 12 + 10 --> 24 + 11 --> 2 + 12 -.-> 32 + 13 -.-> 32 + 15 -.-> 14 + 15 --> 14 + 15 --> 29 + 16 -.-> 17 + 17 --> 18 + 17 --> 21 + 18 --> 9 + 18 --> 20 + 19 -.-> 17 + 20 --> 19 + 21 --> 16 + 23 --> 29 + 24 --> 1 + 25 --> 27 + 25 --> 28 + 26 --> 25 + 30 -.-> 4 + 31 -.-> 9 + 32 --> 10 diff --git a/libs/@local/graph/postgres-store/package.json b/libs/@local/graph/postgres-store/package.json index 20b59a9c1a8..2ae76eade07 100644 --- a/libs/@local/graph/postgres-store/package.json +++ b/libs/@local/graph/postgres-store/package.json @@ -16,6 +16,7 @@ "@rust/error-stack": "workspace:*", "@rust/hash-codec": "workspace:*", "@rust/hash-graph-authorization": "workspace:*", + "@rust/hash-graph-embeddings": "workspace:*", "@rust/hash-graph-store": "workspace:*", "@rust/hash-graph-temporal-versioning": "workspace:*", "@rust/hash-graph-types": "workspace:*", diff --git a/libs/@local/graph/store/docs/dependency-diagram.mmd b/libs/@local/graph/store/docs/dependency-diagram.mmd index c25fc15d926..c4dd3693f57 100644 --- a/libs/@local/graph/store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/store/docs/dependency-diagram.mmd @@ -32,40 +32,35 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[darwin-kperf] - 24[darwin-kperf-criterion] - 25[darwin-kperf-events] - 26[darwin-kperf-sys] - 27[error-stack] - 28[hash-graph-benches] - 29[hash-graph-integration] - 30[hash-graph-test-data] + 23[error-stack] + 24[hash-graph-benches] + 25[hash-graph-integration] + 26[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 30 + 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 8 --> 22 - 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 30 - 12 -.-> 30 + 11 -.-> 26 + 12 -.-> 26 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 27 + 15 --> 23 16 -.-> 17 17 --> 18 17 --> 21 @@ -75,9 +70,6 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 23 --> 25 - 23 --> 26 - 24 --> 23 - 28 -.-> 4 - 29 -.-> 7 - 30 --> 8 + 24 -.-> 4 + 25 -.-> 7 + 26 --> 8 diff --git a/libs/@local/graph/store/package.json b/libs/@local/graph/store/package.json index 04978d129c6..13a00b5532b 100644 --- a/libs/@local/graph/store/package.json +++ b/libs/@local/graph/store/package.json @@ -33,7 +33,6 @@ }, "devDependencies": { "@local/tsconfig": "workspace:*", - "@rust/darwin-kperf-criterion": "workspace:*", "@rust/hash-codegen": "workspace:*", "typescript": "5.9.3" } diff --git a/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd b/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd index 3df7e86b38a..a6593b53518 100644 --- a/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd +++ b/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd @@ -41,13 +41,13 @@ graph TD 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 diff --git a/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd b/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd index d1243620b21..5c3c10fd8c7 100644 --- a/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd +++ b/libs/@local/graph/type-fetcher/docs/dependency-diagram.mmd @@ -22,16 +22,12 @@ graph TD 10[harpc-types] 11[harpc-wire-protocol] 12[hash-temporal-client] - 13[darwin-kperf] - 14[darwin-kperf-criterion] - 15[darwin-kperf-events] - 16[darwin-kperf-sys] - 17[error-stack] - 18[hash-graph-benches] - 19[hash-graph-test-data] + 13[error-stack] + 14[hash-graph-benches] + 15[hash-graph-test-data] 0 --> 4 1 --> 7 - 1 -.-> 19 + 1 -.-> 15 2 -.-> 3 2 --> 11 4 --> 8 @@ -39,16 +35,12 @@ graph TD 6 --> 5 6 --> 9 6 --> 12 - 6 -.-> 14 7 --> 2 8 --> 6 - 9 -.-> 19 + 9 -.-> 15 11 -.-> 10 11 --> 10 - 11 --> 17 + 11 --> 13 12 --> 1 - 13 --> 15 - 13 --> 16 - 14 --> 13 - 18 -.-> 4 - 19 --> 6 + 14 -.-> 4 + 15 --> 6 diff --git a/libs/@local/graph/types/docs/dependency-diagram.mmd b/libs/@local/graph/types/docs/dependency-diagram.mmd index 1b887fb64f8..dfec2861ca5 100644 --- a/libs/@local/graph/types/docs/dependency-diagram.mmd +++ b/libs/@local/graph/types/docs/dependency-diagram.mmd @@ -32,40 +32,35 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[darwin-kperf] - 24[darwin-kperf-criterion] - 25[darwin-kperf-events] - 26[darwin-kperf-sys] - 27[error-stack] - 28[hash-graph-benches] - 29[hash-graph-integration] - 30[hash-graph-test-data] + 23[error-stack] + 24[hash-graph-benches] + 25[hash-graph-integration] + 26[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 30 + 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 8 --> 22 - 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 30 - 12 -.-> 30 + 11 -.-> 26 + 12 -.-> 26 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 27 + 15 --> 23 16 -.-> 17 17 --> 18 17 --> 21 @@ -75,9 +70,6 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 23 --> 25 - 23 --> 26 - 24 --> 23 - 28 -.-> 4 - 29 -.-> 7 - 30 --> 8 + 24 -.-> 4 + 25 -.-> 7 + 26 --> 8 diff --git a/libs/@local/graph/validation/docs/dependency-diagram.mmd b/libs/@local/graph/validation/docs/dependency-diagram.mmd index 4dd6fa9d4c7..a84b3c63ba9 100644 --- a/libs/@local/graph/validation/docs/dependency-diagram.mmd +++ b/libs/@local/graph/validation/docs/dependency-diagram.mmd @@ -29,17 +29,13 @@ graph TD 17[hashql-mir] 18[hashql-syntax-jexpr] 19[hash-temporal-client] - 20[darwin-kperf] - 21[darwin-kperf-criterion] - 22[darwin-kperf-events] - 23[darwin-kperf-sys] - 24[error-stack] - 25[hash-graph-benches] - 26[hash-graph-integration] - 27[hash-graph-test-data] + 20[error-stack] + 21[hash-graph-benches] + 22[hash-graph-integration] + 23[hash-graph-test-data] 0 --> 4 1 --> 8 - 1 -.-> 27 + 1 -.-> 23 2 -.-> 3 2 --> 12 4 --> 15 @@ -49,13 +45,12 @@ graph TD 7 --> 5 7 --> 9 7 --> 19 - 7 -.-> 21 8 --> 2 - 9 -.-> 27 - 10 -.-> 27 + 9 -.-> 23 + 10 -.-> 23 12 -.-> 11 12 --> 11 - 12 --> 24 + 12 --> 20 13 -.-> 14 14 --> 15 14 --> 18 @@ -65,9 +60,6 @@ graph TD 17 --> 16 18 --> 13 19 --> 1 - 20 --> 22 - 20 --> 23 - 21 --> 20 - 25 -.-> 4 - 26 -.-> 6 - 27 --> 7 + 21 -.-> 4 + 22 -.-> 6 + 23 --> 7 diff --git a/libs/@local/harpc/server/docs/dependency-diagram.mmd b/libs/@local/harpc/server/docs/dependency-diagram.mmd index 0f088c57070..c55bd6573e3 100644 --- a/libs/@local/harpc/server/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/server/docs/dependency-diagram.mmd @@ -27,16 +27,12 @@ graph TD 15[harpc-types] 16[harpc-wire-protocol] 17[hash-temporal-client] - 18[darwin-kperf] - 19[darwin-kperf-criterion] - 20[darwin-kperf-events] - 21[darwin-kperf-sys] - 22[error-stack] - 23[hash-graph-benches] - 24[hash-graph-test-data] + 18[error-stack] + 19[hash-graph-benches] + 20[hash-graph-test-data] 0 --> 4 1 --> 7 - 1 -.-> 24 + 1 -.-> 20 2 -.-> 3 2 --> 16 4 --> 6 @@ -45,12 +41,11 @@ graph TD 6 --> 5 6 --> 8 6 --> 17 - 6 -.-> 19 7 --> 2 - 8 -.-> 24 + 8 -.-> 20 9 --> 13 10 --> 15 - 10 --> 22 + 10 --> 18 11 --> 2 11 -.-> 10 11 --> 10 @@ -61,10 +56,7 @@ graph TD 14 --> 11 16 -.-> 15 16 --> 15 - 16 --> 22 + 16 --> 18 17 --> 1 - 18 --> 20 - 18 --> 21 - 19 --> 18 - 23 -.-> 4 - 24 --> 6 + 19 -.-> 4 + 20 --> 6 diff --git a/libs/@local/harpc/types/docs/dependency-diagram.mmd b/libs/@local/harpc/types/docs/dependency-diagram.mmd index 91c4ae06387..e19512e8d9f 100644 --- a/libs/@local/harpc/types/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/types/docs/dependency-diagram.mmd @@ -44,13 +44,13 @@ graph TD 1 --> 8 1 -.-> 30 2 --> 19 - 3 --> 5 3 --> 9 3 --> 15 3 --> 23 3 --> 26 4 --> 1 5 --> 10 + 6 --> 5 6 --> 11 7 --> 4 7 --> 10 diff --git a/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd b/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd index e665aaf4f40..4b9d3502f7b 100644 --- a/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd @@ -44,13 +44,13 @@ graph TD 1 --> 8 1 -.-> 30 2 --> 18 - 3 --> 5 3 --> 9 3 --> 14 3 --> 22 3 --> 25 4 --> 1 5 --> 10 + 6 --> 5 6 --> 11 7 --> 4 7 --> 10 diff --git a/libs/@local/hashql/ast/docs/dependency-diagram.mmd b/libs/@local/hashql/ast/docs/dependency-diagram.mmd index 1d809d023df..49b505b3f35 100644 --- a/libs/@local/hashql/ast/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/ast/docs/dependency-diagram.mmd @@ -14,75 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - class 15 root - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + class 16 root + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 9 -.-> 28 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd index 7fa03c59383..9a2ec12d921 100644 --- a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd @@ -14,75 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - class 16 root - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + class 17 root + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 9 -.-> 28 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/eval/docs/dependency-diagram.mmd b/libs/@local/hashql/eval/docs/dependency-diagram.mmd index ba720211915..0817d44f171 100644 --- a/libs/@local/hashql/eval/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/eval/docs/dependency-diagram.mmd @@ -14,75 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - class 19 root - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + class 20 root + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 9 -.-> 28 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/hir/docs/dependency-diagram.mmd b/libs/@local/hashql/hir/docs/dependency-diagram.mmd index 31c175f2525..d0f49158af5 100644 --- a/libs/@local/hashql/hir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/hir/docs/dependency-diagram.mmd @@ -14,75 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - class 20 root - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + class 21 root + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 9 -.-> 28 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/mir/docs/dependency-diagram.mmd b/libs/@local/hashql/mir/docs/dependency-diagram.mmd index 351c3ebd1f6..08cc3a23141 100644 --- a/libs/@local/hashql/mir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/mir/docs/dependency-diagram.mmd @@ -14,75 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - class 22 root - 23[hashql-syntax-jexpr] - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + class 23 root + 24[hashql-syntax-jexpr] + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 9 -.-> 28 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd index 1abbd2f75d5..659be40ce02 100644 --- a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd @@ -14,75 +14,78 @@ graph TD 3[hash-codegen] 4[hash-graph-api] 5[hash-graph-authorization] - 6[hash-graph-migrations] - 7[hash-graph-migrations-macros] - 8[hash-graph-postgres-store] - 9[hash-graph-store] - 10[hash-graph-temporal-versioning] - 11[hash-graph-types] - 12[hash-graph-validation] - 13[harpc-types] - 14[harpc-wire-protocol] - 15[hashql-ast] - 16[hashql-compiletest] - 17[hashql-core] - 18[hashql-diagnostics] - 19[hashql-eval] - 20[hashql-hir] - 21[hashql-macros] - 22[hashql-mir] - 23[hashql-syntax-jexpr] - class 23 root - 24[hash-status] - 25[hash-telemetry] - 26[hash-temporal-client] - 27[darwin-kperf] - 28[darwin-kperf-criterion] - 29[darwin-kperf-events] - 30[darwin-kperf-sys] - 31[error-stack] - 32[hash-graph-benches] - 33[hash-graph-test-data] + 6[hash-graph-embeddings] + 7[hash-graph-migrations] + 8[hash-graph-migrations-macros] + 9[hash-graph-postgres-store] + 10[hash-graph-store] + 11[hash-graph-temporal-versioning] + 12[hash-graph-types] + 13[hash-graph-validation] + 14[harpc-types] + 15[harpc-wire-protocol] + 16[hashql-ast] + 17[hashql-compiletest] + 18[hashql-core] + 19[hashql-diagnostics] + 20[hashql-eval] + 21[hashql-hir] + 22[hashql-macros] + 23[hashql-mir] + 24[hashql-syntax-jexpr] + class 24 root + 25[hash-status] + 26[hash-telemetry] + 27[hash-temporal-client] + 28[darwin-kperf] + 29[darwin-kperf-criterion] + 30[darwin-kperf-events] + 31[darwin-kperf-sys] + 32[error-stack] + 33[hash-graph-benches] + 34[hash-graph-test-data] 0 --> 4 - 1 --> 10 - 1 -.-> 33 + 1 --> 11 + 1 -.-> 34 2 -.-> 3 - 2 --> 14 - 4 --> 19 - 4 --> 23 + 2 --> 15 + 4 --> 20 + 4 --> 24 5 --> 1 - 6 --> 7 - 6 --> 25 - 8 -.-> 6 - 8 --> 12 - 8 --> 24 - 9 --> 5 - 9 --> 11 - 9 --> 26 - 9 -.-> 28 - 10 --> 2 - 11 -.-> 33 - 12 -.-> 33 - 14 -.-> 13 - 14 --> 13 - 14 --> 31 - 15 -.-> 16 - 16 --> 19 - 16 --> 23 - 17 --> 2 - 17 --> 18 - 17 --> 21 - 17 -.-> 28 - 19 --> 8 - 19 --> 22 - 20 -.-> 16 - 22 --> 20 - 23 --> 15 - 23 --> 17 - 25 --> 31 - 26 --> 1 - 27 --> 29 - 27 --> 30 - 28 --> 27 - 32 -.-> 4 - 33 --> 9 + 6 --> 12 + 6 -.-> 29 + 7 --> 8 + 7 --> 26 + 9 --> 6 + 9 -.-> 7 + 9 --> 13 + 9 --> 25 + 10 --> 5 + 10 --> 12 + 10 --> 27 + 11 --> 2 + 12 -.-> 34 + 13 -.-> 34 + 15 -.-> 14 + 15 --> 14 + 15 --> 32 + 16 -.-> 17 + 17 --> 20 + 17 --> 24 + 18 --> 2 + 18 --> 19 + 18 --> 22 + 18 -.-> 29 + 20 --> 9 + 20 --> 23 + 21 -.-> 17 + 23 --> 21 + 24 --> 16 + 24 --> 18 + 26 --> 32 + 27 --> 1 + 28 --> 30 + 28 --> 31 + 29 --> 28 + 33 -.-> 4 + 34 --> 10 diff --git a/libs/@local/temporal-client/docs/dependency-diagram.mmd b/libs/@local/temporal-client/docs/dependency-diagram.mmd index 1152c9ec8ed..3a4af027246 100644 --- a/libs/@local/temporal-client/docs/dependency-diagram.mmd +++ b/libs/@local/temporal-client/docs/dependency-diagram.mmd @@ -32,40 +32,35 @@ graph TD 21[hashql-syntax-jexpr] 22[hash-temporal-client] class 22 root - 23[darwin-kperf] - 24[darwin-kperf-criterion] - 25[darwin-kperf-events] - 26[darwin-kperf-sys] - 27[error-stack] - 28[hash-graph-benches] - 29[hash-graph-integration] - 30[hash-graph-test-data] + 23[error-stack] + 24[hash-graph-benches] + 25[hash-graph-integration] + 26[hash-graph-test-data] 0 --> 4 1 --> 9 - 1 -.-> 30 + 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 8 --> 22 - 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 30 - 12 -.-> 30 + 11 -.-> 26 + 12 -.-> 26 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 27 + 15 --> 23 16 -.-> 17 17 --> 18 17 --> 21 @@ -75,9 +70,6 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 23 --> 25 - 23 --> 26 - 24 --> 23 - 28 -.-> 4 - 29 -.-> 7 - 30 --> 8 + 24 -.-> 4 + 25 -.-> 7 + 26 --> 8 diff --git a/tests/graph/test-data/rust/docs/dependency-diagram.mmd b/tests/graph/test-data/rust/docs/dependency-diagram.mmd index fc41eb6288a..0390665dcd2 100644 --- a/tests/graph/test-data/rust/docs/dependency-diagram.mmd +++ b/tests/graph/test-data/rust/docs/dependency-diagram.mmd @@ -31,41 +31,36 @@ graph TD 20[hashql-mir] 21[hashql-syntax-jexpr] 22[hash-temporal-client] - 23[darwin-kperf] - 24[darwin-kperf-criterion] - 25[darwin-kperf-events] - 26[darwin-kperf-sys] - 27[error-stack] - 28[hash-graph-benches] - 29[hash-graph-integration] - 30[hash-graph-test-data] - class 30 root + 23[error-stack] + 24[hash-graph-benches] + 25[hash-graph-integration] + 26[hash-graph-test-data] + class 26 root 0 --> 4 1 --> 9 - 1 -.-> 30 + 1 -.-> 26 2 -.-> 3 2 --> 15 - 4 --> 6 4 --> 10 4 --> 13 4 --> 18 4 --> 21 5 --> 1 6 --> 11 + 7 --> 6 7 --> 12 8 --> 5 8 --> 11 8 --> 22 - 8 -.-> 24 9 --> 2 10 --> 8 - 11 -.-> 30 - 12 -.-> 30 + 11 -.-> 26 + 12 -.-> 26 13 -.-> 1 13 --> 14 15 -.-> 14 15 --> 14 - 15 --> 27 + 15 --> 23 16 -.-> 17 17 --> 18 17 --> 21 @@ -75,9 +70,6 @@ graph TD 20 --> 19 21 --> 16 22 --> 1 - 23 --> 25 - 23 --> 26 - 24 --> 23 - 28 -.-> 4 - 29 -.-> 7 - 30 --> 8 + 24 -.-> 4 + 25 -.-> 7 + 26 --> 8 From 5f7fd585ab9ecaf7f9ed956c321a0baa14a2f634 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:36:58 +0200 Subject: [PATCH 28/78] fix: the darn yarn lockfile --- yarn.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index cb4ccc74b26..230a410d724 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13141,6 +13141,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rust/hash-graph-embeddings@workspace:libs/@local/graph/embeddings" dependencies: + "@rust/darwin-kperf-criterion": "workspace:*" "@rust/error-stack": "workspace:*" "@rust/hash-graph-types": "workspace:*" languageName: unknown @@ -13197,6 +13198,7 @@ __metadata: "@rust/error-stack": "workspace:*" "@rust/hash-codec": "workspace:*" "@rust/hash-graph-authorization": "workspace:*" + "@rust/hash-graph-embeddings": "workspace:*" "@rust/hash-graph-migrations": "workspace:*" "@rust/hash-graph-store": "workspace:*" "@rust/hash-graph-temporal-versioning": "workspace:*" @@ -13215,7 +13217,6 @@ __metadata: dependencies: "@blockprotocol/type-system-rs": "workspace:*" "@local/tsconfig": "workspace:*" - "@rust/darwin-kperf-criterion": "workspace:*" "@rust/error-stack": "workspace:*" "@rust/hash-codec": "workspace:*" "@rust/hash-codegen": "workspace:*" From 77ada17179e3dc6119e0a191c5bf0a14c1a751ad Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:47:04 +0200 Subject: [PATCH 29/78] chore: remove tautological tests --- libs/@local/graph/embeddings/src/dimension.rs | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/libs/@local/graph/embeddings/src/dimension.rs b/libs/@local/graph/embeddings/src/dimension.rs index 3701ee85a91..cf4f021ea82 100644 --- a/libs/@local/graph/embeddings/src/dimension.rs +++ b/libs/@local/graph/embeddings/src/dimension.rs @@ -50,42 +50,3 @@ pub const D256: Dimension = Dimension(NonZero::new(256).unwrap()); pub const D512: Dimension = Dimension(NonZero::new(512).unwrap()); pub const D1536: Dimension = Dimension(NonZero::new(1536).unwrap()); pub const D3072: Dimension = Dimension(NonZero::new(3072).unwrap()); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_multiples_of_8() { - for value in [8, 16, 24, 128, 256, 3072] { - assert!( - Dimension::new(value).is_some(), - "{value} should be a valid dimension" - ); - } - } - - #[test] - fn zero_rejected() { - assert!(Dimension::new(0).is_none()); - } - - #[test] - fn non_multiples_of_8_rejected() { - for value in [1, 2, 3, 4, 5, 6, 7, 9, 10, 15, 17, 100, 3071] { - assert!( - Dimension::new(value).is_none(), - "{value} should not be a valid dimension" - ); - } - } - - #[test] - fn constants_have_correct_values() { - assert_eq!(D128.0.get(), 128); - assert_eq!(D256.0.get(), 256); - assert_eq!(D512.0.get(), 512); - assert_eq!(D1536.0.get(), 1536); - assert_eq!(D3072.0.get(), 3072); - } -} From 02d295824ad8ae0103ce13fa4c3cbdd115b287c9 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:57:05 +0200 Subject: [PATCH 30/78] feat: only initialize once. --- apps/hash-graph/src/subcommand/mod.rs | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/hash-graph/src/subcommand/mod.rs b/apps/hash-graph/src/subcommand/mod.rs index e6e7ca8e334..dbb31a7289e 100644 --- a/apps/hash-graph/src/subcommand/mod.rs +++ b/apps/hash-graph/src/subcommand/mod.rs @@ -7,7 +7,7 @@ mod snapshot; mod type_fetcher; use core::time::Duration; -use std::{thread::available_parallelism, time::Instant}; +use std::{sync::Once, thread::available_parallelism, time::Instant}; use clap::Parser; use error_stack::{Report, ensure}; @@ -147,15 +147,18 @@ fn block_on( service_name: &'static str, tracing_config: TracingConfig, ) -> Result<(), Report> { - rayon::ThreadPoolBuilder::new() - .num_threads( - available_parallelism() - .map_or(1, |cores| cores.get() / 2) - .max(1), - ) - .thread_name(|index| format!("rayon-{index}")) - .build_global() - .expect("rayon pool should be initialized exactly once"); + static THREAD_POOL: Once = Once::new(); + THREAD_POOL.call_once(|| { + rayon::ThreadPoolBuilder::new() + .num_threads( + available_parallelism() + .map_or(1, |cores| cores.get() / 2) + .max(1), + ) + .thread_name(|index| format!("rayon-{index}")) + .build_global() + .expect("rayon pool should be initialized exactly once"); + }); tokio::runtime::Builder::new_multi_thread() .enable_all() From 1d4a1f7c6e68de1b2ad3e0ba3b39efacf38cb0c1 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:41:50 +0200 Subject: [PATCH 31/78] fix: docs --- libs/@local/graph/embeddings/benches/clustering.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@local/graph/embeddings/benches/clustering.rs b/libs/@local/graph/embeddings/benches/clustering.rs index 24658df6723..6cd7f86a5be 100644 --- a/libs/@local/graph/embeddings/benches/clustering.rs +++ b/libs/@local/graph/embeddings/benches/clustering.rs @@ -9,7 +9,7 @@ //! spread across the rayon pool and per-thread instruction counts would only see the calling //! thread. //! -//! [`cluster`]: hash_graph_store::embedding::clustering::cluster +//! [`cluster`]: hash_graph_embeddings::clustering::cluster #![expect( unsafe_code, clippy::float_arithmetic, From 0bb8726f5bb2ea9cb915cc1d38e61adc564a8271 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:10:18 +0200 Subject: [PATCH 32/78] feat: move to rayon spawns --- Cargo.lock | 1 + libs/@local/graph/postgres-store/Cargo.toml | 1 + .../store/postgres/knowledge/entity/mod.rs | 94 +++++++++++++++++-- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c4912b58916..b3659f34603 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3789,6 +3789,7 @@ dependencies = [ "indoc", "postgres-types", "pretty_assertions", + "rayon", "refinery", "regex", "semver", diff --git a/libs/@local/graph/postgres-store/Cargo.toml b/libs/@local/graph/postgres-store/Cargo.toml index 114c71642d4..af076b8ab31 100644 --- a/libs/@local/graph/postgres-store/Cargo.toml +++ b/libs/@local/graph/postgres-store/Cargo.toml @@ -40,6 +40,7 @@ derive_more = { workspace = true } dotenv-flow = { workspace = true } futures = { workspace = true } postgres-types = { workspace = true, features = ["derive", "with-serde_json-1"] } +rayon = { workspace = true } refinery = { workspace = true, features = ["tokio-postgres"] } regex = { workspace = true } semver = { workspace = true, features = ["serde"] } diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 719c23e8d00..87629031197 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -4,7 +4,7 @@ mod read; mod summary; use alloc::{borrow::Cow, collections::BTreeMap}; -use core::{borrow::Borrow as _, mem}; +use core::{any::Any, borrow::Borrow as _, fmt, mem}; use std::collections::{HashMap, HashSet}; use error_stack::{FutureExt as _, Report, ResultExt as _, TryReportStreamExt as _, ensure}; @@ -65,6 +65,7 @@ use hash_graph_types::{ use hash_graph_validation::{EntityPreprocessor, Validate as _}; use hash_status::StatusCode; use postgres_types::ToSql; +use tokio::sync::oneshot; use tokio_postgres::{GenericClient as _, error::SqlState}; use tracing::Instrument as _; use type_system::{ @@ -113,6 +114,71 @@ use crate::store::{ validation::StoreProvider, }; +/// The panic that happened during a spawned task. +/// +/// Opaque to fulfil the `Sync` contract, which has the safety requirement that it must be sound for +/// `&JoinError`, to cross thread boundaries. By design, a `&JoinError` has no API whatsoever, +/// making it useless, thus harmless, thus memory safe. +/// +/// This use has precedent, see the nightly `SyncView`, the `SyncWrapper` inside tokio, and +/// `SyncWrapper` of the `sync_wrapper` crate. +struct JoinError(Box); + +// SAFETY: An immutable reference to a `JoinError` is useless, as the value can only be interacted +// with by getting the inner value. This mirrors the design of `SyncView`, see the rationale behind +// it. We choose to implement a custom wrapper instead, to be able to downcast, as long as the +// actual value hidden behind is `Sync`, making the wrapper a no-op, mirroring the internal +// `SyncWrapper` type of tokio, used for it's `JoinError`. +// See: https://github.com/tokio-rs/tokio/blob/c4c6265a0746a79d4a2f3852f726aa0101f29fd3/tokio/src/util/sync_wrapper.rs#L8 +// and: https://github.com/rust-lang/rust/blob/f10db292a3733b5c67c8da8c7661195ff4b05774/library/core/src/sync/sync_view.rs#L90 +#[expect(unsafe_code)] +unsafe impl Sync for JoinError {} + +impl JoinError { + // Adapted from: https://github.com/rust-lang/rust/blob/6c8138de8f1c96b2f66adbbc0e37c73525444750/library/std/src/panicking.rs#L779-L787 + fn message(&self) -> Option<&str> { + if let Some(value) = self.downcast_ref_sync::<&'static str>() { + return Some(*value); + } + + if let Some(value) = self.downcast_ref_sync::() { + return Some(&**value); + } + + None + } + + fn downcast_ref_sync(&self) -> Option<&T> { + // If the downcast fails, the inner value is not touched, so no thread-safety violation can + // occur. + self.0.downcast_ref() + } +} + +impl fmt::Debug for JoinError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = fmt.debug_tuple("JoinError"); + + if let Some(message) = self.message() { + return debug.field(&message).finish(); + } + + debug.finish_non_exhaustive() + } +} + +impl fmt::Display for JoinError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(message) = self.message() { + return write!(fmt, "task panicked with message: {message}"); + } + + fmt.write_str("task panicked") + } +} + +impl core::error::Error for JoinError {} + impl PostgresStore where C: AsClient, @@ -2756,15 +2822,25 @@ where }), ); - let result = tokio::task::spawn_blocking(move || { - hash_graph_embeddings::clustering::cluster(&flat, dimension, &config) - }) - .await - .change_context(ClusterError::Store)?; + let (tx, rx) = oneshot::channel(); + rayon::spawn(move || { + let result = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| { + hash_graph_embeddings::clustering::cluster(&flat, dimension, &config) + })); + + let result = result.map_err(JoinError); + + let _tx = tx.send(result); + }); + + let clustering = rx + .await + .change_context(ClusterError::Store)? + .change_context(ClusterError::Store)?; let mut groups: BTreeMap> = BTreeMap::new(); for (index, id) in found_ids.iter().enumerate() { - groups.entry(result.label(index)).or_default().push(*id); + groups.entry(clustering.label(index)).or_default().push(*id); } let clusters = groups @@ -2772,14 +2848,14 @@ where .map(|(cluster_id, entity_ids)| EntityCluster { cluster_id, entity_ids, - centroid: result.centroid(cluster_id).to_vec(), + centroid: clustering.centroid(cluster_id).to_vec(), }) .collect(); Ok(ClusterEntitiesResponse { clusters, missing_embeddings, - inertia: result.inertia, + inertia: clustering.inertia, }) } } From 4119460ec8b7e29797a800059ce17b5a13895170 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:28:42 +0200 Subject: [PATCH 33/78] feat: test --- Cargo.lock | 1 + .../store/postgres/knowledge/entity/mod.rs | 3 +- tests/graph/integration/Cargo.toml | 1 + tests/graph/integration/package.json | 1 + .../graph/integration/postgres/clustering.rs | 377 ++++++++++++++++++ tests/graph/integration/postgres/lib.rs | 1 + 6 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 tests/graph/integration/postgres/clustering.rs diff --git a/Cargo.lock b/Cargo.lock index b3659f34603..1503da65e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3706,6 +3706,7 @@ dependencies = [ "hash-graph-store", "hash-graph-temporal-versioning", "hash-graph-test-data", + "hash-graph-types", "hash-status", "hash-telemetry", "pretty_assertions", diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 87629031197..8b877f27f49 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -59,6 +59,7 @@ use hash_graph_temporal_versioning::{ TransactionTime, }; use hash_graph_types::{ + Embedding, knowledge::property::visitor::EntityVisitor as _, ontology::{DataTypeLookup, OntologyTypeProvider}, }; @@ -89,7 +90,7 @@ use type_system::{ entity_type::{ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid}, id::{OntologyTypeUuid, VersionedUrl}, }, - principal::actor::ActorEntityUuid, + principal::{actor::ActorEntityUuid, actor_group::WebId}, }; use uuid::Uuid; diff --git a/tests/graph/integration/Cargo.toml b/tests/graph/integration/Cargo.toml index 4e289457f43..da1d6be507f 100644 --- a/tests/graph/integration/Cargo.toml +++ b/tests/graph/integration/Cargo.toml @@ -15,6 +15,7 @@ hash-graph-postgres-store = { workspace = true } hash-graph-store = { workspace = true } hash-graph-temporal-versioning = { workspace = true } hash-graph-test-data = { workspace = true } +hash-graph-types = { workspace = true } hash-status = { workspace = true } hash-telemetry = { workspace = true } type-system = { workspace = true } diff --git a/tests/graph/integration/package.json b/tests/graph/integration/package.json index 8fd6b60ede6..0fdebc36434 100644 --- a/tests/graph/integration/package.json +++ b/tests/graph/integration/package.json @@ -18,6 +18,7 @@ "@rust/hash-graph-store": "workspace:*", "@rust/hash-graph-temporal-versioning": "workspace:*", "@rust/hash-graph-test-data": "workspace:*", + "@rust/hash-graph-types": "workspace:*", "@rust/hash-status": "workspace:*", "@rust/hash-telemetry": "workspace:*" } diff --git a/tests/graph/integration/postgres/clustering.rs b/tests/graph/integration/postgres/clustering.rs new file mode 100644 index 00000000000..a7016fe4229 --- /dev/null +++ b/tests/graph/integration/postgres/clustering.rs @@ -0,0 +1,377 @@ +use core::num::NonZero; +use std::collections::HashSet; + +use hash_graph_store::{ + entity::{ + ClusterEntitiesParams, CreateEntityParams, EntityStore as _, UpdateEntityEmbeddingsParams, + }, + error::ClusterError, +}; +use hash_graph_temporal_versioning::Timestamp; +use hash_graph_test_data::{data_type, entity, entity_type, property_type}; +use hash_graph_types::{Embedding, knowledge::entity::EntityEmbedding}; +use type_system::{ + knowledge::{ + entity::{EntityId, id::EntityUuid, provenance::ProvidedEntityEditionProvenance}, + property::{PropertyObject, PropertyObjectWithMetadata}, + }, + ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + principal::{actor::ActorType, actor_group::WebId}, + provenance::{OriginProvenance, OriginType}, +}; +use uuid::Uuid; + +use crate::{DatabaseApi, DatabaseTestWrapper}; + +/// Dimension used for clustering requests in these tests. +/// +/// Embeddings are stored as 3072-dimensional vectors but matryoshka-truncated +/// server-side, so only the first `CLUSTER_DIM` components carry signal here. +const CLUSTER_DIM: u16 = 8; + +async fn seed(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> { + database + .seed( + [ + data_type::VALUE_V1, + data_type::TEXT_V1, + data_type::NUMBER_V1, + ], + [ + property_type::NAME_V1, + property_type::AGE_V1, + property_type::FAVORITE_SONG_V1, + property_type::FAVORITE_FILM_V1, + property_type::HOBBY_V1, + property_type::INTERESTS_V1, + ], + [ + entity_type::LINK_V1, + entity_type::link::FRIEND_OF_V1, + entity_type::link::ACQUAINTANCE_OF_V1, + entity_type::PERSON_V1, + ], + ) + .await + .expect("could not seed database") +} + +fn person_entity_type_id() -> VersionedUrl { + VersionedUrl { + base_url: BaseUrl::new( + "https://blockprotocol.org/@alice/types/entity-type/person/".to_owned(), + ) + .expect("couldn't construct Base URL"), + version: OntologyTypeVersion { + major: 1, + pre_release: None, + }, + } +} + +async fn create_person(api: &mut DatabaseApi<'_>) -> EntityId { + let person: PropertyObject = + serde_json::from_str(entity::PERSON_ALICE_V1).expect("could not parse entity"); + + api.create_entity( + api.account_id, + CreateEntityParams { + web_id: WebId::new(api.account_id), + entity_uuid: None, + decision_time: None, + entity_type_ids: HashSet::from([person_entity_type_id()]), + properties: PropertyObjectWithMetadata::from_parts(person, None) + .expect("could not create property with metadata object"), + confidence: None, + link_data: None, + draft: false, + policies: Vec::new(), + provenance: ProvidedEntityEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + }, + read_only: false, + }, + ) + .await + .expect("could not create entity") + .metadata + .record_id + .entity_id +} + +/// Builds a full-width stored embedding pointing along `axis` within the first +/// [`CLUSTER_DIM`] components, with a small per-entity `jitter` so vectors in +/// the same group are distinct but remain tightly clustered. +#[expect(clippy::indexing_slicing, clippy::float_arithmetic)] +fn embedding_along_axis(axis: usize, jitter_axis: usize, jitter: f32) -> Embedding<'static> { + assert!(axis < usize::from(CLUSTER_DIM)); + assert!(jitter_axis < usize::from(CLUSTER_DIM)); + + let mut vector = vec![0.0_f32; Embedding::DIM]; + vector[axis] = 1.0; + vector[jitter_axis] += jitter; + Embedding::from(vector) +} + +async fn insert_embedding( + api: &mut DatabaseApi<'_>, + entity_id: EntityId, + embedding: Embedding<'static>, +) { + api.update_entity_embeddings( + api.account_id, + UpdateEntityEmbeddingsParams { + entity_id, + embeddings: vec![EntityEmbedding { + property: None, + embedding, + }], + updated_at_transaction_time: Timestamp::now(), + updated_at_decision_time: Timestamp::now(), + reset: false, + }, + ) + .await + .expect("could not insert entity embedding"); +} + +const fn cluster_params(entity_ids: Vec, cluster_count: u16) -> ClusterEntitiesParams { + ClusterEntitiesParams { + entity_ids, + cluster_count, + dimension: NonZero::new(CLUSTER_DIM).expect("dimension should be non-zero"), + seed: Some(0), + } +} + +#[tokio::test] +async fn clusters_entities_by_embedding_direction() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + // Two well-separated groups: group A points along axis 0, group B along + // axis 1. Spherical k-means with `k = 2` must separate them. + let mut group_a = Vec::new(); + for index in 0..3_usize { + let entity_id = create_person(&mut api).await; + #[expect(clippy::cast_precision_loss, reason = "tiny test-only jitter values")] + insert_embedding( + &mut api, + entity_id, + embedding_along_axis(0, 2, 0.01 * index as f32), + ) + .await; + group_a.push(entity_id); + } + + let mut group_b = Vec::new(); + for index in 0..2_usize { + let entity_id = create_person(&mut api).await; + #[expect(clippy::cast_precision_loss, reason = "tiny test-only jitter values")] + insert_embedding( + &mut api, + entity_id, + embedding_along_axis(1, 3, 0.01 * index as f32), + ) + .await; + group_b.push(entity_id); + } + + // One existing entity without any stored embedding ... + let entity_without_embedding = create_person(&mut api).await; + // ... and one entity ID that does not exist at all. + let nonexistent_entity = EntityId { + web_id: WebId::new(api.account_id), + entity_uuid: EntityUuid::new(Uuid::new_v4()), + draft_id: None, + }; + + let mut requested = group_a.clone(); + requested.extend(&group_b); + requested.push(entity_without_embedding); + requested.push(nonexistent_entity); + + let response = api + .cluster_entities(api.account_id, cluster_params(requested, 2)) + .await + .expect("could not cluster entities"); + + assert_eq!( + response + .missing_embeddings + .iter() + .copied() + .collect::>(), + HashSet::from([entity_without_embedding, nonexistent_entity]), + "entities without embeddings and unknown entities should be reported as missing" + ); + + assert_eq!(response.clusters.len(), 2, "expected exactly two clusters"); + + let group_a_set: HashSet = group_a.iter().copied().collect(); + let group_b_set: HashSet = group_b.iter().copied().collect(); + + let cluster_with_a = response + .clusters + .iter() + .find(|cluster| cluster.entity_ids.contains(&group_a[0])) + .expect("group A should be assigned to a cluster"); + let cluster_with_b = response + .clusters + .iter() + .find(|cluster| cluster.entity_ids.contains(&group_b[0])) + .expect("group B should be assigned to a cluster"); + + assert_eq!( + cluster_with_a + .entity_ids + .iter() + .copied() + .collect::>(), + group_a_set, + "group A should form one cluster" + ); + assert_eq!( + cluster_with_b + .entity_ids + .iter() + .copied() + .collect::>(), + group_b_set, + "group B should form the other cluster" + ); + + for cluster in &response.clusters { + assert_eq!( + cluster.centroid.len(), + usize::from(CLUSTER_DIM), + "centroid length should match the requested dimension" + ); + } + + assert!( + response.inertia < 0.01, + "tightly clustered groups should have near-zero inertia, got {}", + response.inertia + ); +} + +#[tokio::test] +async fn permission_denied_is_reported_as_missing() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + let entity_id = create_person(&mut api).await; + insert_embedding(&mut api, entity_id, embedding_along_axis(0, 1, 0.0)).await; + + // The owning actor can cluster the entity. + let response = api + .cluster_entities(api.account_id, cluster_params(vec![entity_id], 1)) + .await + .expect("could not cluster entities"); + assert_eq!(response.clusters.len(), 1); + assert!(response.missing_embeddings.is_empty()); + + // A machine actor without access to the web must not see the entity. To + // avoid leaking permission information, the entity is reported exactly as + // if it had no embedding. + let machine_id = api.create_machine("clustering-outsider").await; + let response = api + .cluster_entities(machine_id.into(), cluster_params(vec![entity_id], 1)) + .await + .expect("could not cluster entities"); + + assert!( + response.clusters.is_empty(), + "unauthorized entities should not be clustered" + ); + assert_eq!( + response.missing_embeddings, + vec![entity_id], + "unauthorized entities should be indistinguishable from missing embeddings" + ); + assert!(response.inertia.abs() < f32::EPSILON); +} + +#[tokio::test] +async fn zero_cluster_count_returns_no_clusters() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + let entity_id = create_person(&mut api).await; + insert_embedding(&mut api, entity_id, embedding_along_axis(0, 1, 0.0)).await; + + let response = api + .cluster_entities(api.account_id, cluster_params(vec![entity_id], 0)) + .await + .expect("could not cluster entities"); + + assert!(response.clusters.is_empty()); + assert!( + response.missing_embeddings.is_empty(), + "entities with embeddings should not be reported as missing even when `k = 0`" + ); + assert!(response.inertia.abs() < f32::EPSILON); +} + +#[tokio::test] +async fn rejects_invalid_parameters() { + let mut database = DatabaseTestWrapper::new().await; + let api = seed(&mut database).await; + + // Dimension must be a multiple of 8. + let report = api + .cluster_entities( + api.account_id, + ClusterEntitiesParams { + entity_ids: Vec::new(), + cluster_count: 2, + dimension: NonZero::new(7).expect("dimension should be non-zero"), + seed: Some(0), + }, + ) + .await + .expect_err("dimension not a multiple of 8 should be rejected"); + assert!(matches!( + report.current_context(), + ClusterError::InvalidDimension { .. } + )); + + // Dimension must not exceed 512. + let report = api + .cluster_entities( + api.account_id, + ClusterEntitiesParams { + entity_ids: Vec::new(), + cluster_count: 2, + dimension: NonZero::new(520).expect("dimension should be non-zero"), + seed: Some(0), + }, + ) + .await + .expect_err("dimension above 512 should be rejected"); + assert!(matches!( + report.current_context(), + ClusterError::DimensionTooLarge { .. } + )); + + // Cluster count must not exceed 64. + let report = api + .cluster_entities( + api.account_id, + ClusterEntitiesParams { + entity_ids: Vec::new(), + cluster_count: 65, + dimension: NonZero::new(CLUSTER_DIM).expect("dimension should be non-zero"), + seed: Some(0), + }, + ) + .await + .expect_err("cluster count above 64 should be rejected"); + assert!(matches!( + report.current_context(), + ClusterError::KTooLarge { .. } + )); +} diff --git a/tests/graph/integration/postgres/lib.rs b/tests/graph/integration/postgres/lib.rs index f9e54f423bb..59053b59abb 100644 --- a/tests/graph/integration/postgres/lib.rs +++ b/tests/graph/integration/postgres/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; +mod clustering; mod data_type; mod drafts; mod email_filter_protection; From 3fd0938fca241d36c788f588c3673b37314604f1 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:30:31 +0200 Subject: [PATCH 34/78] chore: lockfile --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 230a410d724..9e2bfaa85e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13169,6 +13169,7 @@ __metadata: "@rust/hash-graph-store": "workspace:*" "@rust/hash-graph-temporal-versioning": "workspace:*" "@rust/hash-graph-test-data": "workspace:*" + "@rust/hash-graph-types": "workspace:*" "@rust/hash-status": "workspace:*" "@rust/hash-telemetry": "workspace:*" languageName: unknown From 464e3b0392289aa1bfa5c99bf12d411c7ae9fff4 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:40:39 +0200 Subject: [PATCH 35/78] fix: suggestions from (external) code review --- apps/hash-graph/src/args.rs | 15 +- apps/hash-graph/src/main.rs | 3 +- apps/hash-graph/src/subcommand/mod.rs | 164 ++++++++++++++---- apps/hash-graph/src/subcommand/server.rs | 12 +- libs/@local/graph/api/openapi/openapi.json | 3 +- libs/@local/graph/api/src/rest/entity/mod.rs | 28 ++- libs/@local/graph/api/src/rest/mod.rs | 5 +- .../@local/graph/embeddings/src/clustering.rs | 1 + libs/@local/graph/postgres-store/src/lib.rs | 2 +- .../store/postgres/knowledge/entity/mod.rs | 58 +++---- libs/@local/graph/store/src/entity/store.rs | 2 +- .../graph/integration/postgres/clustering.rs | 2 +- 12 files changed, 221 insertions(+), 74 deletions(-) diff --git a/apps/hash-graph/src/args.rs b/apps/hash-graph/src/args.rs index b3c538276a3..32b717f1316 100644 --- a/apps/hash-graph/src/args.rs +++ b/apps/hash-graph/src/args.rs @@ -7,7 +7,7 @@ use clap::{ }; use hash_telemetry::TracingConfig; -use crate::subcommand::Subcommand; +use crate::subcommand::{Subcommand, WorkerThreads}; /// Arguments passed to the program. #[derive(Debug, Parser)] @@ -16,6 +16,19 @@ pub struct Args { #[clap(flatten)] pub tracing_config: TracingConfig, + /// Number of threads in the global worker pool used for CPU-bound work such as entity + /// clustering. + /// + /// Accepts a fixed count (e.g. `4`) or a count relative to the available CPU cores: `n` for + /// all cores, `n/2` for half, `n/4` for a quarter, and so on. + #[clap( + long, + global = true, + default_value_t, + env = "HASH_GRAPH_WORKER_THREADS" + )] + pub worker_threads: WorkerThreads, + /// Specify a subcommand to run. #[command(subcommand)] pub subcommand: Subcommand, diff --git a/apps/hash-graph/src/main.rs b/apps/hash-graph/src/main.rs index e8ac0681ffc..efdcb62d0b9 100644 --- a/apps/hash-graph/src/main.rs +++ b/apps/hash-graph/src/main.rs @@ -30,9 +30,10 @@ fn main() -> Result<(), Report> { let Args { subcommand, tracing_config, + worker_threads, } = Args::parse_args(); let _sentry_guard = init(&tracing_config.sentry, release_name!()); - subcommand.execute(tracing_config) + subcommand.execute(tracing_config, worker_threads) } diff --git a/apps/hash-graph/src/subcommand/mod.rs b/apps/hash-graph/src/subcommand/mod.rs index dbb31a7289e..d4a4e24d1a2 100644 --- a/apps/hash-graph/src/subcommand/mod.rs +++ b/apps/hash-graph/src/subcommand/mod.rs @@ -6,7 +6,7 @@ mod server; mod snapshot; mod type_fetcher; -use core::time::Duration; +use core::{fmt, num::NonZero, str::FromStr, time::Duration}; use std::{sync::Once, thread::available_parallelism, time::Instant}; use clap::Parser; @@ -15,6 +15,19 @@ use hash_telemetry::{TracingConfig, init_tracing}; use tokio::time::sleep; use tokio_util::{sync::CancellationToken, task::TaskTracker}; +pub use self::{ + admin_server::{AdminServerArgs, admin_server}, + completions::{CompletionsArgs, completions}, + migrate::{MigrateArgs, migrate}, + server::{ServerArgs, server}, + snapshot::{SnapshotArgs, snapshot}, + type_fetcher::{TypeFetcherArgs, type_fetcher}, +}; +use crate::{ + error::{GraphError, HealthcheckError}, + subcommand::reindex_cache::{ReindexCacheArgs, reindex_cache}, +}; + /// Drop guard that fires the `abort` token when a server task exits unexpectedly. /// /// "Unexpectedly" means the `shutdown` token has not been cancelled yet. This covers both @@ -87,6 +100,85 @@ impl ServerLifecycle { } } +/// Number of threads for the global worker pool used for CPU-bound work. +/// +/// Parses either a fixed thread count (e.g. `4`) or a count relative to the number of available +/// CPU cores: `n` for all cores, `n/2` for half of them, `n/4` for a quarter, and so on. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum WorkerThreads { + /// The available CPU cores divided by the given divisor (`n`, `n/2`, `n/4`, ...). + Cores { divisor: NonZero }, + /// A fixed number of threads. + Fixed(NonZero), +} + +impl WorkerThreads { + /// Resolves to a concrete thread count, clamped to at least one thread. + #[expect( + clippy::integer_division, + reason = "Deriving a thread count from the core count is inherently lossy." + )] + fn resolve(self) -> NonZero { + match self { + Self::Fixed(threads) => threads, + Self::Cores { divisor } => available_parallelism() + .ok() + .and_then(|cores| NonZero::new(cores.get() / divisor)) + .unwrap_or(NonZero::::MIN), + } + } +} + +impl Default for WorkerThreads { + fn default() -> Self { + const HALF: NonZero = NonZero::new(2).expect("two should be non-zero"); + Self::Cores { divisor: HALF } + } +} + +impl fmt::Display for WorkerThreads { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Cores { divisor } if divisor == NonZero::::MIN => fmt.write_str("n"), + Self::Cores { divisor } => write!(fmt, "n/{divisor}"), + Self::Fixed(threads) => write!(fmt, "{threads}"), + } + } +} + +/// Error returned when parsing a [`WorkerThreads`] value fails. +#[derive(Debug)] +pub struct ParseWorkerThreadsError; + +impl fmt::Display for ParseWorkerThreadsError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("expected a positive integer, `n`, or `n/` (e.g. `4`, `n`, `n/2`)") + } +} + +impl core::error::Error for ParseWorkerThreadsError {} + +impl FromStr for WorkerThreads { + type Err = ParseWorkerThreadsError; + + fn from_str(value: &str) -> Result { + match value.strip_prefix(['n', 'N']) { + Some("") => Ok(Self::Cores { + divisor: NonZero::::MIN, + }), + Some(rest) => rest + .strip_prefix('/') + .and_then(|divisor| divisor.parse().ok()) + .map(|divisor| Self::Cores { divisor }) + .ok_or(ParseWorkerThreadsError), + None => value + .parse() + .map(Self::Fixed) + .map_err(|_error: core::num::ParseIntError| ParseWorkerThreadsError), + } + } +} + /// Shared healthcheck arguments for all server subcommands. #[derive(Debug, Clone, Parser)] pub(crate) struct HealthcheckArgs { @@ -103,19 +195,6 @@ pub(crate) struct HealthcheckArgs { pub timeout: Option, } -pub use self::{ - admin_server::{AdminServerArgs, admin_server}, - completions::{CompletionsArgs, completions}, - migrate::{MigrateArgs, migrate}, - server::{ServerArgs, server}, - snapshot::{SnapshotArgs, snapshot}, - type_fetcher::{TypeFetcherArgs, type_fetcher}, -}; -use crate::{ - error::{GraphError, HealthcheckError}, - subcommand::reindex_cache::{ReindexCacheArgs, reindex_cache}, -}; - /// Subcommand for the program. #[derive(Debug, clap::Subcommand)] pub enum Subcommand { @@ -141,20 +220,16 @@ pub enum Subcommand { ReindexCache(Box), } -#[expect(clippy::integer_division, clippy::integer_division_remainder_used)] fn block_on( future: impl Future>>, service_name: &'static str, tracing_config: TracingConfig, + worker_threads: WorkerThreads, ) -> Result<(), Report> { static THREAD_POOL: Once = Once::new(); THREAD_POOL.call_once(|| { rayon::ThreadPoolBuilder::new() - .num_threads( - available_parallelism() - .map_or(1, |cores| cores.get() / 2) - .max(1), - ) + .num_threads(worker_threads.resolve().get()) .thread_name(|index| format!("rayon-{index}")) .build_global() .expect("rayon pool should be initialized exactly once"); @@ -173,24 +248,49 @@ fn block_on( } impl Subcommand { - pub(crate) fn execute(self, tracing_config: TracingConfig) -> Result<(), Report> { + pub(crate) fn execute( + self, + tracing_config: TracingConfig, + worker_threads: WorkerThreads, + ) -> Result<(), Report> { match self { - Self::Server(args) => block_on(server(*args), "Graph API", tracing_config), - Self::AdminServer(args) => { - block_on(admin_server(*args), "Graph Admin API", tracing_config) - } - Self::Migrate(args) => block_on(migrate(*args), "Graph Migrations", tracing_config), - Self::TypeFetcher(args) => { - block_on(type_fetcher(*args), "Type Fetcher", tracing_config) + Self::Server(args) => { + block_on(server(*args), "Graph API", tracing_config, worker_threads) } + Self::AdminServer(args) => block_on( + admin_server(*args), + "Graph Admin API", + tracing_config, + worker_threads, + ), + Self::Migrate(args) => block_on( + migrate(*args), + "Graph Migrations", + tracing_config, + worker_threads, + ), + Self::TypeFetcher(args) => block_on( + type_fetcher(*args), + "Type Fetcher", + tracing_config, + worker_threads, + ), Self::Completions(ref args) => { completions(args); Ok(()) } - Self::Snapshot(args) => block_on(snapshot(*args), "Graph Snapshot", tracing_config), - Self::ReindexCache(args) => { - block_on(reindex_cache(*args), "Graph Indexer", tracing_config) - } + Self::Snapshot(args) => block_on( + snapshot(*args), + "Graph Snapshot", + tracing_config, + worker_threads, + ), + Self::ReindexCache(args) => block_on( + reindex_cache(*args), + "Graph Indexer", + tracing_config, + worker_threads, + ), } } } diff --git a/apps/hash-graph/src/subcommand/server.rs b/apps/hash-graph/src/subcommand/server.rs index 8a9bd213701..47922c55b43 100644 --- a/apps/hash-graph/src/subcommand/server.rs +++ b/apps/hash-graph/src/subcommand/server.rs @@ -16,8 +16,8 @@ use harpc_server::Server; use hash_codec::bytes::JsonLinesEncoder; use hash_graph_api::{ rest::{ - ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, hashql::CompilerContext, - rest_api_router, + ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, entity::ClusteringContext, + hashql::CompilerContext, rest_api_router, }, rpc::Dependencies, }; @@ -239,6 +239,13 @@ pub struct ServerConfig { #[clap(flatten)] pub compiler: CompilerConfig, + /// Maximum number of entity-clustering requests processed at the same time. + /// + /// Excess requests wait until a slot frees up. If not set, the number of concurrent + /// clustering requests is unbounded. + #[clap(long, env = "HASH_GRAPH_CLUSTERING_CONCURRENCY_LIMIT")] + pub clustering_concurrency_limit: Option>, + /// Outputs the queries made to the graph to the specified file. #[clap(long)] pub log_queries: Option, @@ -457,6 +464,7 @@ where query_logger, api_config: config.api_config, compiler, + clustering: Arc::new(ClusteringContext::new(config.clustering_concurrency_limit)), }); start_rest_server(router, config.http_address, lifecycle); diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 332ce2efcaa..cc55cd23940 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -3856,7 +3856,8 @@ "items": { "$ref": "#/components/schemas/EntityId" }, - "description": "Entities from the request that had no stored embedding or that do not exist." + "description": "Entities from the request that had no stored embedding or that do not exist.", + "uniqueItems": true } } }, diff --git a/libs/@local/graph/api/src/rest/entity/mod.rs b/libs/@local/graph/api/src/rest/entity/mod.rs index afc026cf3f1..bc10f42d808 100644 --- a/libs/@local/graph/api/src/rest/entity/mod.rs +++ b/libs/@local/graph/api/src/rest/entity/mod.rs @@ -3,10 +3,12 @@ pub mod query; use alloc::sync::Arc; +use core::num::NonZero; use std::collections::HashMap; use axum::{Extension, Router, routing::post}; use error_stack::{Report, ResultExt as _}; +use futures::future::OptionFuture; use hash_graph_authorization::policies::principal::actor::AuthenticatedActor; use hash_graph_embeddings::OpenAiEmbeddingClient; use hash_graph_postgres_store::store::error::{EntityDoesNotExist, RaceConditionOnUpdate}; @@ -45,6 +47,7 @@ use hash_graph_types::{ }; use hash_temporal_client::TemporalClient; use serde::Deserialize as _; +use tokio::sync::Semaphore; use type_system::{ knowledge::{ Confidence, Entity, Property, @@ -606,6 +609,19 @@ where .map_err(report_to_response) } +pub struct ClusteringContext { + pub limit: Option, +} + +impl ClusteringContext { + #[must_use] + pub fn new(concurrency_limit: Option>) -> Self { + Self { + limit: concurrency_limit.map(|limit| Semaphore::new(limit.get())), + } + } +} + #[utoipa::path( post, path = "/entities/embeddings/clusters", @@ -623,15 +639,21 @@ where )] async fn cluster_entities( AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, + Extension(store_pool): Extension>, + Extension(temporal_client): Extension>>, + Extension(context): Extension>, Json(params): Json, ) -> Result, BoxedResponse> where S: StorePool + Send + Sync, { + let _permit = OptionFuture::from(context.limit.as_ref().map(Semaphore::acquire)) + .await + .transpose() + .expect("semaphore should never be closed"); + let store = store_pool - .acquire(temporal_client.0) + .acquire(temporal_client) .await .map_err(report_to_response)?; diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index 3ebd2a169d9..00e6751be6d 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -100,6 +100,7 @@ use utoipa::{ use uuid::Uuid; use self::{ + entity::ClusteringContext, status::{BoxedResponse, report_to_response, status_to_response}, utoipa_typedef::{ MaybeListOfDataTypeMetadata, MaybeListOfEntityTypeMetadata, @@ -543,6 +544,7 @@ where pub query_logger: Option, pub api_config: ApiConfig, pub compiler: Arc, + pub clustering: Arc, } /// A [`Router`] that only serves the `OpenAPI` specification (JSON, and necessary subschemas) for @@ -593,7 +595,8 @@ where .layer(Extension(dependencies.embedding_client)) .layer(Extension(dependencies.domain_regex)) .layer(Extension(dependencies.api_config)) - .layer(Extension(dependencies.compiler)); + .layer(Extension(dependencies.compiler)) + .layer(Extension(Arc::new(dependencies.clustering))); if let Some(query_logger) = dependencies.query_logger { router = router.layer(Extension(query_logger)); diff --git a/libs/@local/graph/embeddings/src/clustering.rs b/libs/@local/graph/embeddings/src/clustering.rs index 45111c4b8f6..0837494cf08 100644 --- a/libs/@local/graph/embeddings/src/clustering.rs +++ b/libs/@local/graph/embeddings/src/clustering.rs @@ -27,6 +27,7 @@ use super::{dimension::Dimension, kernel}; /// /// Use [`Config::for_k_with_seed`] to construct with reasonable defaults, then override individual /// fields as needed. +#[derive(Debug, Copy, Clone)] pub struct Config { /// Number of clusters. pub k: u16, diff --git a/libs/@local/graph/postgres-store/src/lib.rs b/libs/@local/graph/postgres-store/src/lib.rs index 4fc05125e87..7733674896f 100644 --- a/libs/@local/graph/postgres-store/src/lib.rs +++ b/libs/@local/graph/postgres-store/src/lib.rs @@ -9,7 +9,7 @@ // Library Features extend_one, - iter_intersperse, + iter_intersperse )] #![cfg_attr(not(miri), doc(test(attr(deny(warnings, clippy::all)))))] #![expect( diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 8b877f27f49..4a2eca1817c 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -3,7 +3,7 @@ mod query; mod read; mod summary; -use alloc::{borrow::Cow, collections::BTreeMap}; +use alloc::borrow::Cow; use core::{any::Any, borrow::Borrow as _, fmt, mem}; use std::collections::{HashMap, HashSet}; @@ -17,7 +17,7 @@ use hash_graph_authorization::policies::{ resource::{EntityResourceConstraint, ResourceConstraint}, store::{PolicyCreationParams, PrincipalStore as _}, }; -use hash_graph_embeddings::Dimension; +use hash_graph_embeddings::{Dimension, clustering::Clustering}; use hash_graph_store::{ entity::{ ClusterEntitiesParams, ClusterEntitiesResponse, CreateEntityParams, DeleteEntitiesParams, @@ -2673,9 +2673,9 @@ where actor_id: ActorEntityUuid, params: ClusterEntitiesParams, ) -> Result> { - const { assert!(Embedding::DIM <= u16::MAX as usize) }; const MAX_ALLOWED_DIM: u16 = 512; const MAX_ALLOWED_K: u16 = 64; + const { assert!(Embedding::DIM <= u16::MAX as usize) }; let dimension = Dimension::new(params.dimension.get()).ok_or_else(|| { Report::new(ClusterError::InvalidDimension { @@ -2719,15 +2719,15 @@ where .await .change_context(ClusterError::Store)?; - let permitted_ids: Vec = params + let permitted_ids: Vec<_> = params .entity_ids .iter() .filter(|&id| permitted.contains_key(id)) .copied() .collect(); - let entity_uuids: Vec = permitted_ids.iter().map(|id| id.entity_uuid).collect(); - let web_ids: Vec = permitted_ids.iter().map(|id| id.web_id).collect(); + let entity_uuids: Vec<_> = permitted_ids.iter().map(|id| id.entity_uuid).collect(); + let web_ids: Vec<_> = permitted_ids.iter().map(|id| id.web_id).collect(); // Truncate server-side via `subvector` so postgres only sends // `truncated_dim`-dimensional vectors over the wire. @@ -2765,8 +2765,12 @@ where let mut row_stream = core::pin::pin!(row_stream); - let mut flat: Vec = Vec::with_capacity(permitted_ids.len() * truncated_dim); - let mut found_ids: Vec = Vec::with_capacity(permitted_ids.len()); + let mut flat: Vec<_> = Vec::with_capacity(permitted_ids.len() * truncated_dim); + let mut found_ids: Vec<_> = Vec::with_capacity(permitted_ids.len()); + + // Every requested entity not in a cluster goes into `missing_embeddings`, whether due to + // permissions or no embedding. Distinguishing the two would leak permission information. + let mut missing_ids: HashSet<_> = params.entity_ids.iter().copied().collect(); while let Some(row) = row_stream .try_next() @@ -2779,30 +2783,19 @@ where flat.extend(embedding.iter()); - found_ids.push(EntityId { + let id = EntityId { web_id, entity_uuid, draft_id: None, - }); + }; + found_ids.push(id); + missing_ids.remove(&id); } - // Every requested entity not in a cluster goes into - // `missing_embeddings`, whether due to permissions or no embedding. - // Distinguishing the two would leak permission information. - let found_set: HashSet<(WebId, EntityUuid)> = found_ids - .iter() - .map(|id| (id.web_id, id.entity_uuid)) - .collect(); - let missing_embeddings: Vec = params - .entity_ids - .into_iter() - .filter(|id| !found_set.contains(&(id.web_id, id.entity_uuid))) - .collect(); - if found_ids.is_empty() || params.cluster_count == 0 { return Ok(ClusterEntitiesResponse { clusters: Vec::new(), - missing_embeddings, + missing_embeddings: missing_ids, inertia: 0.0, }); } @@ -2831,7 +2824,7 @@ where let result = result.map_err(JoinError); - let _tx = tx.send(result); + let _: Result<(), Result> = tx.send(result); }); let clustering = rx @@ -2839,14 +2832,19 @@ where .change_context(ClusterError::Store)? .change_context(ClusterError::Store)?; - let mut groups: BTreeMap> = BTreeMap::new(); - for (index, id) in found_ids.iter().enumerate() { - groups.entry(clustering.label(index)).or_default().push(*id); + let mut groups = vec![Vec::new(); config.k as usize]; + + #[expect(clippy::indexing_slicing, reason = "we only ever have k groups")] + for (index, &id) in found_ids.iter().enumerate() { + let label = clustering.label(index) as usize; + groups[label].push(id); } let clusters = groups .into_iter() - .map(|(cluster_id, entity_ids)| EntityCluster { + .zip(0_u16..) + .filter(|(entity_ids, _)| !entity_ids.is_empty()) + .map(|(entity_ids, cluster_id)| EntityCluster { cluster_id, entity_ids, centroid: clustering.centroid(cluster_id).to_vec(), @@ -2855,7 +2853,7 @@ where Ok(ClusterEntitiesResponse { clusters, - missing_embeddings, + missing_embeddings: missing_ids, inertia: clustering.inertia, }) } diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index 063cce3a7ec..d73dd9254dc 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -581,7 +581,7 @@ pub struct ClusterEntitiesResponse { /// are omitted. pub clusters: Vec, /// Entities from the request that had no stored embedding or that do not exist. - pub missing_embeddings: Vec, + pub missing_embeddings: HashSet, /// Sum of squared chord distances from every clustered entity to its /// assigned centroid. Lower is tighter; comparable across runs over the /// same entities, e.g. to choose a cluster count. `0.0` when nothing was diff --git a/tests/graph/integration/postgres/clustering.rs b/tests/graph/integration/postgres/clustering.rs index a7016fe4229..da10b0a90cd 100644 --- a/tests/graph/integration/postgres/clustering.rs +++ b/tests/graph/integration/postgres/clustering.rs @@ -289,7 +289,7 @@ async fn permission_denied_is_reported_as_missing() { ); assert_eq!( response.missing_embeddings, - vec![entity_id], + HashSet::from([entity_id]), "unauthorized entities should be indistinguishable from missing embeddings" ); assert!(response.inertia.abs() < f32::EPSILON); From 0aa7d67377b263b3ff65ba765187010828cd4237 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:04:56 +0200 Subject: [PATCH 36/78] feat: dedupe query and make it deterministic --- libs/@local/graph/api/src/rest/mod.rs | 2 +- .../store/postgres/knowledge/entity/mod.rs | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index 00e6751be6d..114ac3a3748 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -596,7 +596,7 @@ where .layer(Extension(dependencies.domain_regex)) .layer(Extension(dependencies.api_config)) .layer(Extension(dependencies.compiler)) - .layer(Extension(Arc::new(dependencies.clustering))); + .layer(Extension(dependencies.clustering)); if let Some(query_logger) = dependencies.query_logger { router = router.layer(Extension(query_logger)); diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 4a2eca1817c..d7d388b69a8 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -2740,14 +2740,24 @@ where .query_raw( &format!( "SELECT - e.web_id, - e.entity_uuid, + u.web_id, + u.entity_uuid, subvector(e.embedding, 1, {truncated_dim})::vector({truncated_dim}) AS \ embedding - FROM entity_embeddings e + FROM ( + SELECT DISTINCT ON (t.web_id, t.entity_uuid) + t.web_id, + t.entity_uuid, + t.ord + FROM unnest($1::uuid[], $2::uuid[]) + WITH ORDINALITY AS t(web_id, entity_uuid, ord) + ORDER BY t.web_id, t.entity_uuid, t.ord + ) u + JOIN entity_embeddings e + ON e.web_id = u.web_id + AND e.entity_uuid = u.entity_uuid WHERE e.property IS NULL - AND (e.web_id, e.entity_uuid) IN (SELECT * FROM unnest($1::uuid[], \ - $2::uuid[]))" + ORDER BY u.ord" ), [ &web_ids as &(dyn ToSql + Sync), From 90538ebe72a3ec6c343dd7df847c4f8892cd84f4 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:26:15 +0200 Subject: [PATCH 37/78] fix: docs --- libs/@local/graph/store/src/entity/store.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/store/src/entity/store.rs b/libs/@local/graph/store/src/entity/store.rs index d73dd9254dc..ca2a0b1458a 100644 --- a/libs/@local/graph/store/src/entity/store.rs +++ b/libs/@local/graph/store/src/entity/store.rs @@ -580,7 +580,8 @@ pub struct ClusterEntitiesResponse { /// One entry per non-empty cluster. Empty clusters (no points assigned) /// are omitted. pub clusters: Vec, - /// Entities from the request that had no stored embedding or that do not exist. + /// Entities from the request that were not clustered (either because no embedding exists, or + /// because the actor lacks permission to view the entity). pub missing_embeddings: HashSet, /// Sum of squared chord distances from every clustered entity to its /// assigned centroid. Lower is tighter; comparable across runs over the From 50be867791b929dfe03aa4a985e80f24d3f61fd2 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:34:57 +0200 Subject: [PATCH 38/78] fix: schema --- libs/@local/graph/api/openapi/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index cc55cd23940..041fe7f5621 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -3856,7 +3856,7 @@ "items": { "$ref": "#/components/schemas/EntityId" }, - "description": "Entities from the request that had no stored embedding or that do not exist.", + "description": "Entities from the request that were not clustered (either because no embedding exists, or\nbecause the actor lacks permission to view the entity).", "uniqueItems": true } } From e1e9473f908346c686592305b8b79f06461c1c9f Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:43:17 +0200 Subject: [PATCH 39/78] feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: embedding clustering feat: embedding clustering feat: embedding clustering feat: embedding clustering feat: checkpoint feat: checkpoint feat: checkpoint fix: merge feat: checkpoint feat: checkpoint feat: checkpoint fix: merge feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint] feat: checkpoint] feat: checkpoint] feat: checkpoint feat: checkpoint --- .cursor/hooks.json | 11 + .impeccable/live/config.json | 6 + PRODUCT.md | 39 + .../knowledge/primitive/cluster-entities.ts | 33 + apps/hash-api/src/index.ts | 4 + apps/hash-frontend/next.config.js | 19 + apps/hash-frontend/package.json | 11 + apps/hash-frontend/src/pages/_app.page.tsx | 1 + .../src/pages/dev-graph-visualizer.page.tsx | 22 + .../hash-frontend/src/pages/entities.page.tsx | 1 + .../src/pages/shared/entities-visualizer.tsx | 85 +- .../shared/filter-state-url.test.ts | 326 ++ .../shared/filter-state-url.ts | 372 ++ .../shared/use-url-synced-filter-state.ts | 68 + .../use-entities-visualizer-data.tsx | 24 +- .../src/pages/shared/entity/entity-editor.tsx | 3 + .../entity/entity-editor/graph-section.tsx | 164 + .../entity/shared/entity-editor-tabs.tsx | 16 +- .../shared/graph-visualizer-2/CONVENTIONS.md | 51 + .../shared/graph-visualizer-2/INTERACTION.md | 188 + .../shared/graph-visualizer-2/LAYOUT-MODES.md | 283 ++ .../shared/graph-visualizer-2/MANIFESTO.md | 414 ++ .../graph-visualizer-2/WORKER-REFACTOR.md | 106 + .../pages/shared/graph-visualizer-2/brand.ts | 90 + .../components/dev-harness-controls-panel.tsx | 272 ++ .../components/entity-hover-card.tsx | 364 ++ .../components/entity-label-overlay.tsx | 67 + .../components/frontier-cluster-card.tsx | 163 + .../components/frontier-controls.tsx | 167 + .../components/frontier-legend.tsx | 51 + .../components/graph-controls.tsx | 51 + .../components/graph-guidance-card.tsx | 46 + .../components/graph-overlay-panel.tsx | 38 + .../components/graph-status-overlay.tsx | 40 + .../components/highway-summary-card.tsx | 237 ++ .../pages/shared/graph-visualizer-2/config.ts | 154 + .../shared/graph-visualizer-2/dev-harness.tsx | 247 ++ .../dev-harness/generate-fixture.test.ts | 93 + .../dev-harness/generate-fixture.ts | 70 + .../generate-fixture/build-entities.ts | 237 ++ .../generate-fixture/build-type-maps.ts | 254 ++ .../shared/graph-visualizer-2/dim-color.ts | 20 + .../entity-graph-visualizer.tsx | 802 ++++ .../fetch-frontier-expansion.ts | 97 + .../pages/shared/graph-visualizer-2/frames.ts | 277 ++ .../shared/graph-visualizer-2/geometry.ts | 115 + .../graph-visualizer-2/graph-visualizer.tsx | 137 + .../pages/shared/graph-visualizer-2/ids.ts | 50 + .../interactivity/frontier-expansion.ts | 27 + .../interactivity/graph-camera-commands.ts | 1 + .../use-graph-guidance-dismissal.ts | 24 + .../graph-visualizer-2/render/clusters.ts | 298 ++ .../graph-visualizer-2/render/community.ts | 148 + .../graph-visualizer-2/render/edge-arrows.ts | 119 + .../graph-visualizer-2/render/edges.test.ts | 53 + .../shared/graph-visualizer-2/render/edges.ts | 121 + .../graph-visualizer-2/render/flat-dots.ts | 65 + .../render/gpu/bezier-sdf-layer.ts | 475 +++ .../render/gpu/bubble-set-sdf-layer.ts | 294 ++ .../render/gpu/endpoint-v-layer.ts | 237 ++ .../render/gpu/icon-atlas.ts | 298 ++ .../graph-visualizer-2/render/labels.ts | 164 + .../shared/graph-visualizer-2/render/scene.ts | 1687 ++++++++ .../graph-visualizer-2/render/selection.ts | 147 + .../graph-visualizer-2/render/type-icons.ts | 243 ++ .../render/use-graph-worker.ts | 70 + .../render/worker-connection.ts | 661 ++++ .../shared/graph-visualizer-2/visual-style.ts | 78 + .../worker/.impeccable/hook.cache.json | 22 + .../worker/buffers/entity-id-buffer.test.ts | 78 + .../worker/buffers/entity-id-buffer.ts | 53 + .../worker/buffers/growable-buffer.ts | 189 + .../worker/buffers/position-buffer.test.ts | 74 + .../worker/buffers/position-buffer.ts | 272 ++ .../worker/collections/bitset.ts | 145 + .../worker/collections/column.ts | 188 + .../worker/collections/interner.ts | 55 + .../worker/collections/readonly-sorted-set.ts | 82 + .../worker/core/graph-worker.ts | 2972 +++++++++++++++ .../worker/core/layout-reuse.test.ts | 93 + .../worker/core/layout-reuse.ts | 84 + .../worker/core/viewport-anchor.test.ts | 79 + .../worker/core/viewport-anchor.ts | 50 + .../worker/csr-graph.test.ts | 61 + .../graph-visualizer-2/worker/csr-graph.ts | 111 + .../worker/entity-id-codec.test.ts | 56 + .../worker/entity-id-codec.ts | 70 + .../worker/entity-style.test.ts | 171 + .../graph-visualizer-2/worker/entity-style.ts | 184 + .../shared/graph-visualizer-2/worker/entry.ts | 193 + .../worker/geometry/bubble-ports.ts | 493 +++ .../worker/geometry/edge-aggregation.ts | 863 +++++ .../worker/geometry/edge-geometry.ts | 1559 ++++++++ .../geometry/entity-fanout-exit.test.ts | 50 + .../worker/geometry/feeder-continuity.test.ts | 170 + .../worker/geometry/world-positions.test.ts | 106 + .../worker/geometry/world-positions.ts | 60 + .../hierarchy/cluster-feature-source.ts | 133 + .../worker/hierarchy/cluster-radii.test.ts | 43 + .../worker/hierarchy/cluster-tree.ts | 1382 +++++++ .../worker/hierarchy/community.ts | 451 +++ .../distinctive-cluster-label.test.ts | 208 + .../hierarchy/distinctive-cluster-label.ts | 617 +++ .../worker/hierarchy/lod.ts | 264 ++ .../worker/layout/cluster-layout.ts | 392 ++ .../worker/layout/community-layout.test.ts | 258 ++ .../worker/layout/community-layout.ts | 700 ++++ .../worker/layout/entity-layout.ts | 111 + .../worker/layout/flat-layout.test.ts | 173 + .../worker/layout/flat-layout.ts | 397 ++ .../worker/layout/force-simulation.ts | 285 ++ .../worker/layout/forceatlas2-iterate.d.ts | 23 + .../worker/layout/sparse-stress-seed.test.ts | 159 + .../worker/layout/sparse-stress-seed.ts | 2707 +++++++++++++ .../worker/layout/top-level-layout.test.ts | 323 ++ .../worker/layout/top-level-layout.ts | 645 ++++ .../worker/layout/untangle.ts | 520 +++ .../graph-visualizer-2/worker/protocol.ts | 316 ++ .../graph-visualizer-2/worker/random.test.ts | 41 + .../graph-visualizer-2/worker/random.ts | 34 + .../worker/stores/entity-store.test.ts | 106 + .../worker/stores/entity-store.ts | 116 + .../worker/stores/link-store.ts | 164 + .../worker/stores/property-store.ts | 255 ++ .../worker/stores/type-registry.test.ts | 148 + .../worker/stores/type-registry.ts | 251 ++ .../worker/stores/type-set-store.ts | 128 + .../graph-visualizer/SPEC-graph-viz-v2.md | 3389 +++++++++++++++++ .../src/pages/shared/slide-stack.tsx | 4 + .../shared/slide-stack/link-table-slide.tsx | 335 ++ .../src/pages/shared/slide-stack/types.ts | 14 +- tools/embedding-worker/Dockerfile.postgres | 10 + tools/embedding-worker/README.md | 77 + tools/embedding-worker/docker-compose.yml | 25 + tools/embedding-worker/embed.py | 845 ++++ tools/embedding-worker/test_api.py | 28 + 136 files changed, 36231 insertions(+), 25 deletions(-) create mode 100644 .cursor/hooks.json create mode 100644 .impeccable/live/config.json create mode 100644 PRODUCT.md create mode 100644 apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts create mode 100644 apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts create mode 100644 apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/CONVENTIONS.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/INTERACTION.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/LAYOUT-MODES.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/MANIFESTO.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/WORKER-REFACTOR.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/brand.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/dev-harness-controls-panel.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-hover-card.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-label-overlay.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-cluster-card.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-controls.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-legend.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-controls.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-guidance-card.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-overlay-panel.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-status-overlay.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/highway-summary-card.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/config.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-type-maps.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/dim-color.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/fetch-frontier-expansion.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/geometry.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/graph-visualizer.tsx create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/frontier-expansion.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/graph-camera-commands.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/use-graph-guidance-dismissal.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bezier-sdf-layer.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/endpoint-v-layer.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/entity-fanout-exit.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/feeder-continuity.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-radii.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/cluster-layout.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/entity-layout.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forceatlas2-iterate.d.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer/SPEC-graph-viz-v2.md create mode 100644 apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx create mode 100644 tools/embedding-worker/Dockerfile.postgres create mode 100644 tools/embedding-worker/README.md create mode 100644 tools/embedding-worker/docker-compose.yml create mode 100644 tools/embedding-worker/embed.py create mode 100644 tools/embedding-worker/test_api.py diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000000..28f4bde9882 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "command": "node \"/Users/bmahmoud/.cursor/skills/impeccable/scripts/hook-before-edit.mjs\"", + "timeout": 5 + } + ] + } +} diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 00000000000..dd8a3eb5271 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,6 @@ +{ + "files": ["apps/hash-frontend/src/pages/_document.page.tsx"], + "insertBefore": "", + "commentSyntax": "jsx", + "cspChecked": true +} diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000000..4c2f819c540 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,39 @@ +# Product + +## Register + +product + +## Users + +HASH app users exploring entity relationships quickly; data and modeling power users inspecting schemas, links, and graph structure; analysts who need relationship insight they can trust. + +## Product Purpose + +Graph visualizer v2 is a focused workstream inside the HASH frontend. It helps users turn filtered entity sets into understandable relationship maps, identify hubs, bundled links, frontier entities, and move from graph insight back into entity or table details. + +Success means the graph feels like a reliable orientation and action surface for knowledge work, not a decorative visualization demo. + +## Brand Personality + +Trustworthy, precise, calm. Expert without intimidation. The visualizer should feel responsive, readable, restrained, and grounded in the HASH product language. + +## Anti-references + +Flashy force-directed graph demos, sci-fi or neon graph aesthetics, over-decorated analytics dashboards, graph canvases with no explanation or control surface, generic admin UI where every state is gray and undifferentiated, and name-on-every-dot clutter. + +## Design Principles + +Orient before detail. The surface should make clear what the user is looking at, what is selected or loading, and what actions are available before asking them to interpret dense graph structure. + +Encode meaning consistently. Color, labels, size, dimming, bundled edges, frontier styling, and interaction states must map to stable graph concepts. + +Keep expert density legible. Preserve information richness while using hierarchy, progressive disclosure, and precise copy to reduce cognitive load. + +Make interaction discoverable. Pan, zoom, hover, click, frontier expansion, aggregated link tables, and mode changes need visible affordances rather than hidden knowledge. + +Prioritize trust under scale. Loading, streaming, layout settling, performance degradation, empty states, and error states should feel deliberate and recoverable. + +## Accessibility & Inclusion + +Use WCAG 2.2 AA as the baseline. User-facing controls and entity/table pathways should be keyboard navigable. Motion should respect reduced-motion preferences. Graph meaning should not rely on color alone; use labels, contrast, size, shape, pattern, or copy where practical. Color choices should remain usable for common color-vision deficiencies, with readable text contrast over both canvas and panel backgrounds. diff --git a/apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts b/apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts new file mode 100644 index 00000000000..c7587e22bf1 --- /dev/null +++ b/apps/hash-api/src/graph/knowledge/primitive/cluster-entities.ts @@ -0,0 +1,33 @@ +import { getActorIdFromRequest } from "../../../auth/get-actor-id"; + +import type { ClusterEntitiesParams } from "@local/hash-graph-client"; +import type { RequestHandler } from "express"; + +export const clusterEntitiesHandler: RequestHandler< + Record, + | { + clusters: { + clusterId: number; + entityIds: string[]; + centroid: number[]; + }[]; + missingEmbeddings: string[]; + } + | { error: string }, + ClusterEntitiesParams +> = async (req, res) => { + const actorId = getActorIdFromRequest(req); + + try { + const { data } = await req.context.graphApi.clusterEntities( + actorId, + req.body, + ); + + res.status(200).json(data); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}; diff --git a/apps/hash-api/src/index.ts b/apps/hash-api/src/index.ts index e8bbafb5fce..935318a7810 100644 --- a/apps/hash-api/src/index.ts +++ b/apps/hash-api/src/index.ts @@ -63,6 +63,7 @@ import { kratosPublicUrl } from "./auth/ory-kratos"; import { setupBlockProtocolExternalServiceMethodProxy } from "./block-protocol-external-service-method-proxy"; import { createEmailTransporter } from "./email/create-email-transporter"; import { ensureSystemGraphIsInitialized } from "./graph/ensure-system-graph-is-initialized"; +import { clusterEntitiesHandler } from "./graph/knowledge/primitive/cluster-entities"; import { ensureHashSystemAccountExists } from "./graph/system-account"; import { createApolloServer } from "./graphql/create-apollo-server"; import { otelSetup } from "./instrument.mjs"; @@ -843,6 +844,9 @@ const main = async () => { next(); }); + // Entity clustering + app.post("/entities/embeddings/clusters", clusterEntitiesHandler); + // Integrations app.get("/oauth/linear", authRouteRateLimiter, oAuthLinear); app.get("/oauth/linear/callback", authRouteRateLimiter, oAuthLinearCallback); diff --git a/apps/hash-frontend/next.config.js b/apps/hash-frontend/next.config.js index 62d9b0e6860..09a913356fd 100644 --- a/apps/hash-frontend/next.config.js +++ b/apps/hash-frontend/next.config.js @@ -158,6 +158,25 @@ export default withSentryConfig( }, ], }, + { + /** + * Enable SharedArrayBuffer for the graph visualizer worker. + * COOP: same-origin isolates the browsing context. + * COEP: credentialless is less restrictive than require-corp + * (no need for Cross-Origin-Resource-Policy on every resource). + */ + source: "/:path*", + headers: [ + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Cross-Origin-Embedder-Policy", + value: "credentialless", + }, + ], + }, ]; }, pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.jsx", "api.ts"], diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index f2fb883088e..80cdee5eb0a 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -28,6 +28,11 @@ "@blockprotocol/hook": "0.1.8", "@blockprotocol/service": "0.1.5", "@blockprotocol/type-system": "workspace:*", + "@deck.gl/core": "9.3.4", + "@deck.gl/extensions": "9.3.4", + "@deck.gl/layers": "9.3.4", + "@deck.gl/react": "9.3.4", + "@deck.gl/widgets": "9.3.4", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "7.0.2", "@dnd-kit/utilities": "3.2.2", @@ -52,6 +57,8 @@ "@local/hash-graph-sdk": "workspace:*", "@local/hash-isomorphic-utils": "workspace:*", "@local/status": "workspace:*", + "@luma.gl/core": "9.3.5", + "@luma.gl/engine": "9.3.5", "@mantine/hooks": "8.3.5", "@mui/icons-material": "5.18.0", "@mui/material": "5.18.0", @@ -72,6 +79,7 @@ "@tldraw/primitives": "2.0.0-alpha.12", "@tldraw/tldraw": "2.0.0-alpha.12", "@tldraw/tlvalidate": "2.0.0-alpha.12", + "@types/d3-force": "3.0.10", "@types/prismjs": "1.26.5", "@vercel/edge-config": "0.4.1", "@xyflow/react": "12.10.1", @@ -79,6 +87,7 @@ "axios": "1.16.1", "buffer": "6.0.3", "clsx": "2.1.1", + "d3-force": "3.0.0", "date-fns": "4.1.0", "dompurify": "3.4.11", "dotenv-flow": "3.3.0", @@ -87,6 +96,7 @@ "fractional-indexing": "3.2.0", "framer-motion": "11.18.2", "graphology": "0.26.0", + "graphology-communities-louvain": "2.0.2", "graphology-layout": "0.6.1", "graphology-layout-forceatlas2": "0.10.1", "graphology-shortest-path": "2.1.0", @@ -139,6 +149,7 @@ "use-font-face-observer": "1.3.0", "uuid": "14.0.0", "web-worker": "1.4.1", + "webcola": "3.4.0", "zod": "4.4.3" }, "devDependencies": { diff --git a/apps/hash-frontend/src/pages/_app.page.tsx b/apps/hash-frontend/src/pages/_app.page.tsx index d849418ebb8..6d5f8006c43 100644 --- a/apps/hash-frontend/src/pages/_app.page.tsx +++ b/apps/hash-frontend/src/pages/_app.page.tsx @@ -304,6 +304,7 @@ const AppWithTypeSystemContextProvider: AppPage = ( // The list of page pathnames that should be accessible whether or not the user is authenticated const publiclyAccessiblePagePathnames = [ "/[shortname]/[page-slug]", + ...(process.env.NODE_ENV === "development" ? ["/dev-graph-visualizer"] : []), "/signin", "/signup", "/verification", diff --git a/apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx b/apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx new file mode 100644 index 00000000000..539358822b6 --- /dev/null +++ b/apps/hash-frontend/src/pages/dev-graph-visualizer.page.tsx @@ -0,0 +1,22 @@ +import { Box } from "@mui/material"; + +import { DevHarness } from "./shared/graph-visualizer-2/dev-harness"; + +import type { NextPageWithLayout } from "../shared/layout"; + +/** + * Dev-only route hosting the graph-visualizer dev harness full-viewport, with no app chrome + * (`getLayout` returns the page unchanged). Lets a developer iterate on + * {@link EntityGraphVisualizerV2} against synthetic data. + */ +const DevGraphVisualizerPage: NextPageWithLayout = () => { + return ( + + + + ); +}; + +DevGraphVisualizerPage.getLayout = (page) => page; + +export default DevGraphVisualizerPage; diff --git a/apps/hash-frontend/src/pages/entities.page.tsx b/apps/hash-frontend/src/pages/entities.page.tsx index 271b1e37a84..394e5295faf 100644 --- a/apps/hash-frontend/src/pages/entities.page.tsx +++ b/apps/hash-frontend/src/pages/entities.page.tsx @@ -346,6 +346,7 @@ const EntitiesPage: NextPageWithLayout = () => { entityTypeBaseUrl={entityTypeBaseUrl} entityTypeId={entityTypeId} hideColumns={entityTypeId ? ["entityTypes"] : []} + persistFilterStateInUrl /> diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index d90152846c9..1a6c11760ed 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -27,10 +27,12 @@ import { VisualizerHeader, visualizerHeaderHeight, } from "./entities-visualizer/header"; -import { createDefaultFilterState } from "./entities-visualizer/shared/filter-state"; +import { serializeFilterStateToQuery } from "./entities-visualizer/shared/filter-state-url"; import { useAvailableTypes } from "./entities-visualizer/shared/use-available-types"; +import { useUrlSyncedFilterState } from "./entities-visualizer/shared/use-url-synced-filter-state"; import { useEntitiesVisualizerData } from "./entities-visualizer/use-entities-visualizer-data"; -import { EntityGraphVisualizer } from "./entity-graph-visualizer"; +// import { EntityGraphVisualizer } from "./entity-graph-visualizer"; +import { EntityGraphVisualizerV2 } from "./graph-visualizer-2/entity-graph-visualizer"; import { useSlideStack } from "./slide-stack"; import { TableHeaderToggle } from "./table-header-toggle"; import { TOP_CONTEXT_BAR_HEIGHT } from "./top-context-bar"; @@ -144,7 +146,19 @@ export const EntitiesVisualizer: FunctionComponent<{ * Hide specific columns from the table */ hideColumns?: (keyof EntitiesTableRow)[]; -}> = ({ entityTypeBaseUrl, entityTypeId, hideColumns }) => { + /** + * Persist the active filter state in the URL query string (hydrating from it + * on mount), so that refreshing, bookmarking, or sharing the page preserves + * the filters. Defaults to `false` for embedded usages (e.g. an entity type's + * entities tab) that should not take over the URL. + */ + persistFilterStateInUrl?: boolean; +}> = ({ + entityTypeBaseUrl, + entityTypeId, + hideColumns, + persistFilterStateInUrl = false, +}) => { const theme = useTheme(); const { authenticatedUser } = useAuthenticatedUser(); @@ -178,8 +192,30 @@ export const EntitiesVisualizer: FunctionComponent<{ }, ); - const [filterState, _setFilterState] = useState(() => - createDefaultFilterState(internalWebs.map(({ webId }) => webId)), + const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; + + const [filterState, _setFilterState] = useUrlSyncedFilterState({ + enabled: persistFilterStateInUrl, + internalWebs, + isTypePinned, + }); + + // Identity of the graph's data SOURCE: the type scoping plus the canonical (URL-grade) filter + // serialization. Pagination (cursor) and sort/conversions don't change which entities the graph + // shows, so they're excluded -- a change here means the entity set was REPLACED, not extended, + // which the graph visualizer uses to purge and re-ingest rather than append stale data. + const graphSourceKey = useMemo( + () => + JSON.stringify({ + entityTypeBaseUrl, + entityTypeId, + filter: serializeFilterStateToQuery({ + filterState, + internalWebIds: internalWebs.map(({ webId }) => webId), + isTypePinned, + }), + }), + [entityTypeBaseUrl, entityTypeId, filterState, internalWebs, isTypePinned], ); const [cursor, setCursor] = useState(); @@ -202,19 +238,11 @@ export const EntitiesVisualizer: FunctionComponent<{ ); const setFilterState = useCallback( - ( - newFilterStateOrUpdater: - | EntitiesFilterState - | ((prev: EntitiesFilterState) => EntitiesFilterState), - ) => { - _setFilterState((prev) => - typeof newFilterStateOrUpdater === "function" - ? newFilterStateOrUpdater(prev) - : newFilterStateOrUpdater, - ); + (newFilterStateOrUpdater: SetStateAction) => { + _setFilterState(newFilterStateOrUpdater); setCursor(undefined); }, - [setCursor], + [_setFilterState, setCursor], ); const [view, _setView] = useState("Table"); @@ -278,6 +306,7 @@ export const EntitiesVisualizer: FunctionComponent<{ cursor: nextCursor, definitions, entities, + rootEntityIds, closedMultiEntityTypes: closedMultiEntityTypesRootMap, subgraph, } = visualizerData; @@ -346,8 +375,6 @@ export const EntitiesVisualizer: FunctionComponent<{ } }, [entitiesData]); - const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; - const { availableEntityTypes, propertyFilterData, @@ -491,6 +518,17 @@ export const EntitiesVisualizer: FunctionComponent<{ [pushToSlideStack], ); + const handleOpenLinkTable = useCallback( + (linkEntityIds: readonly EntityId[]) => { + pushToSlideStack({ + kind: "linkTable", + itemId: `linkTable:${linkEntityIds[0] ?? "empty"}:${linkEntityIds.length}`, + linkEntityIds: [...linkEntityIds], + }); + }, + [pushToSlideStack], + ); + const currentlyDisplayedColumnsRef = useRef(null); const currentlyDisplayedRowsRef = useRef(null); @@ -524,7 +562,7 @@ export const EntitiesVisualizer: FunctionComponent<{ const tableHeight = `min(${availableHeight}, 1000px)`; - const isPrimaryEntity = useCallback( + const _isPrimaryEntity = useCallback( (entity: { metadata: Pick }) => entityTypeBaseUrl ? entity.metadata.entityTypeIds.some( @@ -630,14 +668,17 @@ export const EntitiesVisualizer: FunctionComponent<{ ) : view === "Graph" ? ( - } - isPrimaryEntity={isPrimaryEntity} onEntityClick={handleEntityClick} + onOpenLinkTable={handleOpenLinkTable} /> ) : view === "Grid" ? ( diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts new file mode 100644 index 00000000000..40deef0fe49 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.test.ts @@ -0,0 +1,326 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { createDefaultFilterState } from "./filter-state"; +import { + applyFilterValuesToAsPath, + parseFilterStateFromQuery, + serializeFilterStateToQuery, +} from "./filter-state-url"; + +import type { EntitiesFilterState } from "./filter-state"; +import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; + +const webA = "11111111-1111-4111-8111-111111111111" as WebId; +const webB = "22222222-2222-4222-8222-222222222222" as WebId; +const internalWebIds = [webA, webB]; + +const personType = + "https://hash.ai/@hash/types/entity-type/person/v/1" as VersionedUrl; +const orgType = + "https://hash.ai/@hash/types/entity-type/organization/v/1" as VersionedUrl; +const unitOfMeasureBaseUrl = + "https://hash.ai/@hash/types/property-type/unit-of-measure/" as BaseUrl; + +const roundTrip = ( + state: EntitiesFilterState, + isTypePinned = false, +): EntitiesFilterState => + parseFilterStateFromQuery({ + query: serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned, + }), + internalWebIds, + isTypePinned, + }); + +describe("filter-state-url", () => { + it("serializes the default state to an empty query", () => { + expect( + serializeFilterStateToQuery({ + filterState: createDefaultFilterState(internalWebIds), + internalWebIds, + isTypePinned: false, + }), + ).toEqual({}); + }); + + it("parses an empty query to the default state", () => { + expect( + parseFilterStateFromQuery({ + query: {}, + internalWebIds, + isTypePinned: false, + }), + ).toEqual(createDefaultFilterState(internalWebIds)); + }); + + it("round-trips a single selected web", () => { + const state = createDefaultFilterState(internalWebIds); + state.web.selectedInternalWebIds = new Set([webA]); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ webs: webA }); + expect(roundTrip(state)).toEqual(state); + }); + + it("round-trips 'any' (all internal + other webs)", () => { + const state = createDefaultFilterState(internalWebIds); + state.web.includeOtherWebs = true; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ otherWebs: "true" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("round-trips 'none' (no internal webs selected)", () => { + const state = createDefaultFilterState(internalWebIds); + state.web.selectedInternalWebIds = new Set(); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ webs: "none" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("serializes types as a comma-separated list and round-trips", () => { + const state = createDefaultFilterState(internalWebIds); + state.type.selectedTypeIds = new Set([personType, orgType]); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ types: `${personType},${orgType}` }); + expect(roundTrip(state)).toEqual(state); + }); + + it("round-trips empty type selection", () => { + const state = createDefaultFilterState(internalWebIds); + state.type.selectedTypeIds = new Set(); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ types: "none" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("ignores the type dimension when pinned", () => { + const state = createDefaultFilterState(internalWebIds); + state.type.selectedTypeIds = new Set([personType]); + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: true, + }); + expect(values).toEqual({}); + + const parsed = roundTrip(state, true); + expect(parsed.type.selectedTypeIds).toBeNull(); + }); + + it("round-trips includeArchived", () => { + const state = createDefaultFilterState(internalWebIds); + state.includeArchived = true; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ archived: "true" }); + expect(roundTrip(state)).toEqual(state); + }); + + it("serializes property filters compactly and re-derives the title from the slug", () => { + const state = createDefaultFilterState(internalWebIds); + state.propertyFilters = [ + { + id: "ignored-on-serialize", + baseUrl: unitOfMeasureBaseUrl, + title: "Unit of Measure", + kind: "string", + operator: "equals", + value: "2", + }, + ]; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ + propertyFilter: [`${unitOfMeasureBaseUrl};string;equals;2`], + }); + + const parsed = roundTrip(state); + expect(parsed.propertyFilters).toHaveLength(1); + const [filter] = parsed.propertyFilters; + expect(filter).toMatchObject({ + baseUrl: unitOfMeasureBaseUrl, + kind: "string", + operator: "equals", + value: "2", + // Title is dropped from the URL and re-derived from the slug. + title: "Unit Of Measure", + }); + expect(filter!.id).not.toBe("ignored-on-serialize"); + }); + + it("preserves field separators that appear inside a property filter value", () => { + const state = createDefaultFilterState(internalWebIds); + state.propertyFilters = [ + { + id: "x", + baseUrl: unitOfMeasureBaseUrl, + title: "Unit of Measure", + kind: "string", + operator: "contains", + value: "a;b;c", + }, + ]; + + const parsed = roundTrip(state); + expect(parsed.propertyFilters[0]).toMatchObject({ value: "a;b;c" }); + }); + + it("omits the value for value-less property filter operators", () => { + const state = createDefaultFilterState(internalWebIds); + state.propertyFilters = [ + { + id: "x", + baseUrl: unitOfMeasureBaseUrl, + title: "Unit of Measure", + kind: "string", + operator: "isEmpty", + }, + ]; + + const values = serializeFilterStateToQuery({ + filterState: state, + internalWebIds, + isTypePinned: false, + }); + expect(values).toEqual({ + propertyFilter: [`${unitOfMeasureBaseUrl};string;isEmpty`], + }); + + const parsed = roundTrip(state); + expect(parsed.propertyFilters[0]).toMatchObject({ + operator: "isEmpty", + value: undefined, + }); + }); + + it("drops malformed property filters on parse", () => { + const parsed = parseFilterStateFromQuery({ + query: { + propertyFilter: [ + "not-a-url;number;equals;1", + `${unitOfMeasureBaseUrl};number;notAnOperator`, + `${unitOfMeasureBaseUrl};string;equals;5`, + ], + }, + internalWebIds, + isTypePinned: false, + }); + expect(parsed.propertyFilters).toHaveLength(1); + expect(parsed.propertyFilters[0]).toMatchObject({ value: "5" }); + }); + + it("filters out web ids that are not internal", () => { + const parsed = parseFilterStateFromQuery({ + query: { webs: `${webA},99999999-9999-4999-8999-999999999999` }, + internalWebIds, + isTypePinned: false, + }); + expect([...parsed.web.selectedInternalWebIds]).toEqual([webA]); + }); + + describe("applyFilterValuesToAsPath", () => { + it("keeps type URLs human-readable (no percent-encoded slashes/colons)", () => { + const { nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities", + filterValues: { types: personType }, + }); + expect(nextAsPath).toBe(`/entities?types=${personType}`); + expect(nextAsPath).not.toContain("%3A"); + expect(nextAsPath).not.toContain("%2F"); + }); + + it("adds filter params while preserving existing ones", () => { + const { changed, nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities?entityTypeIdOrBaseUrl=foo", + filterValues: { webs: webA, archived: "true" }, + }); + expect(changed).toBe(true); + expect(nextAsPath).toBe( + `/entities?entityTypeIdOrBaseUrl=foo&webs=${webA}&archived=true`, + ); + }); + + it("reports no change when the params already match", () => { + const { changed } = applyFilterValuesToAsPath({ + asPath: `/entities?archived=true`, + filterValues: { archived: "true" }, + }); + expect(changed).toBe(false); + }); + + it("removes filter params that are no longer present", () => { + const { changed, nextAsPath } = applyFilterValuesToAsPath({ + asPath: `/entities?webs=${webA}&archived=true`, + filterValues: { webs: webA }, + }); + expect(changed).toBe(true); + expect(nextAsPath).toBe(`/entities?webs=${webA}`); + }); + + it("emits one repeated parameter per property filter", () => { + const { nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities", + filterValues: { + propertyFilter: [ + `${unitOfMeasureBaseUrl};string;equals;2`, + `${unitOfMeasureBaseUrl};string;contains;x`, + ], + }, + }); + expect(nextAsPath).toBe( + `/entities?propertyFilter=${unitOfMeasureBaseUrl};string;equals;2` + + `&propertyFilter=${unitOfMeasureBaseUrl};string;contains;x`, + ); + }); + + it("encodes spaces in values as %20 (not +) while keeping separators raw", () => { + const { nextAsPath } = applyFilterValuesToAsPath({ + asPath: "/entities", + filterValues: { + propertyFilter: [`${unitOfMeasureBaseUrl};string;equals;John Doe`], + }, + }); + expect(nextAsPath).toContain("%20"); + expect(nextAsPath).not.toContain("+"); + expect(nextAsPath).toContain(`${unitOfMeasureBaseUrl};string;equals;`); + }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts new file mode 100644 index 00000000000..d6c829a81cf --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/filter-state-url.ts @@ -0,0 +1,372 @@ +import { isBaseUrl } from "@blockprotocol/type-system"; + +import { getOperatorDescriptor } from "./property-filters/get-operators-for-kind"; + +import type { EntitiesFilterState } from "./filter-state"; +import type { + FilterValueKind, + PropertyFilter, + PropertyFilterOperator, +} from "./property-filters/property-filter"; +import type { VersionedUrl, WebId } from "@blockprotocol/type-system"; + +/** + * The shape of `router.query` (structurally equivalent to Node's + * `ParsedUrlQuery`), declared locally to avoid importing a Node built-in into + * frontend code. + */ +type UrlQuery = Record; + +const WEBS_KEY = "webs"; +const OTHER_WEBS_KEY = "otherWebs"; +const TYPES_KEY = "types"; +const ARCHIVED_KEY = "archived"; +/** Repeated once per property filter (e.g. `?propertyFilter=…&propertyFilter=…`). */ +const PROPERTY_FILTER_KEY = "propertyFilter"; + +/** + * The query parameter keys owned by the entities filter state. Used when + * merging filter state into an existing URL so that unrelated parameters (e.g. + * `entityTypeIdOrBaseUrl`) are left untouched. + */ +export const filterStateQueryKeys = [ + WEBS_KEY, + OTHER_WEBS_KEY, + TYPES_KEY, + ARCHIVED_KEY, + PROPERTY_FILTER_KEY, +] as const; + +/** + * Sentinel used for an explicitly empty selection, distinguishing it from an + * absent parameter (which means "default", i.e. everything selected). + */ +const NONE_TOKEN = "none"; +const TRUE_TOKEN = "true"; + +/** Separator for the comma-delimited web id / type id lists. */ +const LIST_SEPARATOR = ","; +/** + * Field separator within a single serialized property filter + * (`baseUrl;kind;operator;value`). The value comes last so it may itself + * contain the separator. + */ +const FIELD_SEPARATOR = ";"; + +const filterValueKinds: FilterValueKind[] = ["number", "string", "boolean"]; + +const isFilterValueKind = (value: string): value is FilterValueKind => + filterValueKinds.includes(value as FilterValueKind); + +const getSingleValue = ( + value: string | string[] | undefined, +): string | undefined => { + const single = Array.isArray(value) ? value[0] : value; + // Treat an empty string the same as an absent parameter. + return single === undefined || single === "" ? undefined : single; +}; + +const getMultiValue = (value: string | string[] | undefined): string[] => { + if (value === undefined) { + return []; + } + return (Array.isArray(value) ? value : [value]).filter( + (entry) => entry !== "", + ); +}; + +/** + * Derives a display title from a property type's base URL slug, used as a + * fallback for filters restored from the URL (which omit the title to stay + * compact). e.g. `…/property-type/unit-of-measure/` -> "Unit Of Measure". + */ +const titleFromBaseUrl = (baseUrl: string): string => { + const segments = baseUrl.split("/").filter((segment) => segment.length > 0); + const slug = segments[segments.length - 1] ?? baseUrl; + return slug + .split("-") + .map((word) => (word ? `${word[0]!.toUpperCase()}${word.slice(1)}` : word)) + .join(" "); +}; + +let urlPropertyFilterIdCounter = 0; + +const parsePropertyFilter = (raw: string): PropertyFilter | null => { + const [baseUrl, kind, operator, ...valueParts] = raw.split(FIELD_SEPARATOR); + + if (baseUrl === undefined || !isBaseUrl(baseUrl)) { + return null; + } + + if (kind === undefined || !isFilterValueKind(kind)) { + return null; + } + + if ( + operator === undefined || + !getOperatorDescriptor(kind, operator as PropertyFilterOperator) + ) { + return null; + } + + urlPropertyFilterIdCounter += 1; + + return { + id: `url-property-filter-${urlPropertyFilterIdCounter}`, + baseUrl, + title: titleFromBaseUrl(baseUrl), + kind, + operator: operator as PropertyFilterOperator, + value: valueParts.length ? valueParts.join(FIELD_SEPARATOR) : undefined, + }; +}; + +const serializePropertyFilter = ({ + baseUrl, + kind, + operator, + value, +}: PropertyFilter): string => { + const fields: string[] = [baseUrl, kind, operator]; + if (value !== undefined) { + fields.push(value); + } + return fields.join(FIELD_SEPARATOR); +}; + +const parseWebState = ({ + websValue, + otherWebsValue, + internalWebIds, +}: { + websValue: string | undefined; + otherWebsValue: string | undefined; + internalWebIds: WebId[]; +}): EntitiesFilterState["web"] => { + const includeOtherWebs = otherWebsValue === TRUE_TOKEN; + + let selectedInternalWebIds: Set; + + if (websValue === undefined) { + selectedInternalWebIds = new Set(internalWebIds); + } else if (websValue === NONE_TOKEN) { + selectedInternalWebIds = new Set(); + } else { + const internalWebIdSet = new Set(internalWebIds); + selectedInternalWebIds = new Set( + websValue + .split(LIST_SEPARATOR) + .filter((id): id is WebId => internalWebIdSet.has(id as WebId)), + ); + } + + return { selectedInternalWebIds, includeOtherWebs }; +}; + +const parseTypeState = ( + typesValue: string | undefined, +): EntitiesFilterState["type"] => { + if (typesValue === undefined) { + return { selectedTypeIds: null }; + } + + if (typesValue === NONE_TOKEN) { + return { selectedTypeIds: new Set() }; + } + + return { + selectedTypeIds: new Set( + typesValue + .split(LIST_SEPARATOR) + .filter((id) => id.length > 0) as VersionedUrl[], + ), + }; +}; + +/** + * Builds an {@link EntitiesFilterState} from the URL query, falling back to the + * defaults (all webs, all types, archived hidden, no property filters) for any + * absent or malformed parameter. + * + * When the type is pinned (the visualizer is scoped to a specific entity type) + * the type dimension is ignored, mirroring how the type filter pill is hidden. + */ +export const parseFilterStateFromQuery = ({ + query, + internalWebIds, + isTypePinned, +}: { + query: UrlQuery; + internalWebIds: WebId[]; + isTypePinned: boolean; +}): EntitiesFilterState => ({ + web: parseWebState({ + websValue: getSingleValue(query[WEBS_KEY]), + otherWebsValue: getSingleValue(query[OTHER_WEBS_KEY]), + internalWebIds, + }), + type: isTypePinned + ? { selectedTypeIds: null } + : parseTypeState(getSingleValue(query[TYPES_KEY])), + includeArchived: getSingleValue(query[ARCHIVED_KEY]) === TRUE_TOKEN, + propertyFilters: getMultiValue(query[PROPERTY_FILTER_KEY]) + .map(parsePropertyFilter) + .filter((filter): filter is PropertyFilter => filter !== null), +}); + +/** + * Serializes an {@link EntitiesFilterState} to a map of (decoded) query + * parameter values, omitting any dimension that is at its default value so that + * a pristine filter state produces a clean URL. Property filters are emitted as + * a repeated parameter, one entry per filter. + */ +export const serializeFilterStateToQuery = ({ + filterState, + internalWebIds, + isTypePinned, +}: { + filterState: EntitiesFilterState; + internalWebIds: WebId[]; + isTypePinned: boolean; +}): Record => { + const values: Record = {}; + + const { selectedInternalWebIds, includeOtherWebs } = filterState.web; + + const selectedValidWebIds = internalWebIds.filter((id) => + selectedInternalWebIds.has(id), + ); + const allWebsSelected = selectedValidWebIds.length === internalWebIds.length; + + if (!allWebsSelected) { + values[WEBS_KEY] = + selectedValidWebIds.length === 0 + ? NONE_TOKEN + : selectedValidWebIds.join(LIST_SEPARATOR); + } + + if (includeOtherWebs) { + values[OTHER_WEBS_KEY] = TRUE_TOKEN; + } + + if (!isTypePinned) { + const { selectedTypeIds } = filterState.type; + + if (selectedTypeIds !== null) { + values[TYPES_KEY] = + selectedTypeIds.size === 0 + ? NONE_TOKEN + : [...selectedTypeIds].join(LIST_SEPARATOR); + } + } + + if (filterState.includeArchived) { + values[ARCHIVED_KEY] = TRUE_TOKEN; + } + + if (filterState.propertyFilters.length > 0) { + values[PROPERTY_FILTER_KEY] = filterState.propertyFilters.map( + serializePropertyFilter, + ); + } + + return values; +}; + +/** + * Percent-encodes a query value while leaving characters that are legal in a URL + * query and aid readability (`:` `/` `@` `,` `;`) intact, so type and property + * URLs remain legible rather than turning into `%3A%2F%2F…` noise. + */ +const encodeQueryValue = (value: string): string => + encodeURIComponent(value) + .replace(/%3A/g, ":") + .replace(/%2F/g, "/") + .replace(/%40/g, "@") + .replace(/%2C/g, ",") + .replace(/%3B/g, ";"); + +const parseRawPairs = (search: string): [string, string][] => + search + ? search.split("&").map((pair): [string, string] => { + const equalsIndex = pair.indexOf("="); + return equalsIndex === -1 + ? [pair, ""] + : [pair.slice(0, equalsIndex), pair.slice(equalsIndex + 1)]; + }) + : []; + +const groupRawValuesByKey = ( + pairs: [string, string][], +): Map => { + const byKey = new Map(); + for (const [key, rawValue] of pairs) { + const existing = byKey.get(key); + if (existing) { + existing.push(rawValue); + } else { + byKey.set(key, [rawValue]); + } + } + return byKey; +}; + +const arraysEqual = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((value, index) => value === b[index]); + +/** + * Merges the given filter parameter values into an existing `asPath`, leaving + * any non-filter parameters in place. Comparison is performed against the raw + * (already-encoded) query so the result is stable across re-renders, and values + * are written with {@link encodeQueryValue} to keep URLs readable. Returns + * whether the resulting path differs so callers can avoid redundant navigations. + */ +export const applyFilterValuesToAsPath = ({ + asPath, + filterValues, +}: { + asPath: string; + filterValues: Record; +}): { changed: boolean; nextAsPath: string } => { + const [path = "", search = ""] = asPath.split("?"); + const existingPairs = parseRawPairs(search); + const currentByKey = groupRawValuesByKey(existingPairs); + + const desiredByKey = new Map(); + for (const key of filterStateQueryKeys) { + const value = filterValues[key]; + if (value === undefined) { + continue; + } + const list = Array.isArray(value) ? value : [value]; + desiredByKey.set(key, list.map(encodeQueryValue)); + } + + const changed = filterStateQueryKeys.some( + (key) => + !arraysEqual(currentByKey.get(key) ?? [], desiredByKey.get(key) ?? []), + ); + + if (!changed) { + return { changed: false, nextAsPath: asPath }; + } + + const ownedKeys = new Set(filterStateQueryKeys); + + const rebuilt = existingPairs + .filter(([key]) => !ownedKeys.has(key)) + .map(([key, rawValue]) => (rawValue === "" ? key : `${key}=${rawValue}`)); + + for (const key of filterStateQueryKeys) { + for (const rawValue of desiredByKey.get(key) ?? []) { + rebuilt.push(`${key}=${rawValue}`); + } + } + + const nextSearch = rebuilt.join("&"); + + return { + changed: true, + nextAsPath: nextSearch ? `${path}?${nextSearch}` : path, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts new file mode 100644 index 00000000000..cff4bcb9c13 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/shared/use-url-synced-filter-state.ts @@ -0,0 +1,68 @@ +import { useRouter } from "next/router"; +import { useEffect, useMemo, useState } from "react"; + +import { createDefaultFilterState } from "./filter-state"; +import { + applyFilterValuesToAsPath, + parseFilterStateFromQuery, + serializeFilterStateToQuery, +} from "./filter-state-url"; + +import type { EntitiesFilterState } from "./filter-state"; +import type { WebId } from "@blockprotocol/type-system"; +import type { Dispatch, SetStateAction } from "react"; + +/** + * Owns the entities visualizer filter state, optionally persisting it to (and + * hydrating it from) the URL query string. + * + * When `enabled`, the initial state is read from the current URL and any + * subsequent change is written back via a shallow `router.replace`, so that + * refreshing, bookmarking, or sharing the page preserves the active filters. + * When disabled, this behaves like a plain `useState` seeded with the defaults. + */ +export const useUrlSyncedFilterState = ({ + enabled, + internalWebs, + isTypePinned, +}: { + enabled: boolean; + internalWebs: { webId: WebId }[]; + isTypePinned: boolean; +}): [EntitiesFilterState, Dispatch>] => { + const { asPath, query, replace } = useRouter(); + + const internalWebIds = useMemo( + () => internalWebs.map(({ webId }) => webId), + [internalWebs], + ); + + const [filterState, setFilterState] = useState(() => + enabled + ? parseFilterStateFromQuery({ query, internalWebIds, isTypePinned }) + : createDefaultFilterState(internalWebIds), + ); + + useEffect(() => { + if (!enabled) { + return; + } + + const filterValues = serializeFilterStateToQuery({ + filterState, + internalWebIds, + isTypePinned, + }); + + const { changed, nextAsPath } = applyFilterValuesToAsPath({ + asPath, + filterValues, + }); + + if (changed) { + void replace(nextAsPath, undefined, { shallow: true, scroll: false }); + } + }, [enabled, filterState, internalWebIds, isTypePinned, asPath, replace]); + + return [filterState, setFilterState]; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx index af55f4d430e..c409f0b745a 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx @@ -33,7 +33,12 @@ import type { import type { EntitiesFilterState } from "./shared/filter-state"; import type { ApolloQueryResult } from "@apollo/client"; import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; -import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; +import type { + BaseUrl, + EntityId, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; import type { EntityQueryCursor, EntityQuerySortingRecord, @@ -46,6 +51,8 @@ export type EntitiesVisualizerData = Partial< > > & { entities?: HashEntity[]; + /** Graph view only: EntityIds of the query roots; the rest of `entities` are frontier nodes. */ + rootEntityIds?: EntityId[]; hadCachedContent: boolean; loading: boolean; /** @@ -195,10 +202,24 @@ export const useEntitiesVisualizerData = (params: { [subgraph, view], ); + /** + * The query ROOTS' EntityIds (Graph view only). In the Graph view `entities` is every fetched + * vertex; those not in this set are FRONTIER nodes -- link endpoints the graph renders + * greyed-out until expanded. + */ + const rootEntityIds = useMemo( + () => + subgraph && view === "Graph" + ? getRoots(subgraph).map((entity) => entity.metadata.recordId.entityId) + : undefined, + [subgraph, view], + ); + return useMemo( () => ({ ...data?.queryEntitySubgraph, entities, + rootEntityIds, hadCachedContent, loading, refetch, @@ -211,6 +232,7 @@ export const useEntitiesVisualizerData = (params: { data?.queryEntitySubgraph, summaryData?.summarizeEntities, entities, + rootEntityIds, hadCachedContent, loading, refetch, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx index 49c4e684d69..09236e9d7f5 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx @@ -6,6 +6,7 @@ import { getRoots } from "@blockprotocol/graph/stdlib"; import { ClaimsSection } from "./entity-editor/claims-section"; import { EntityEditorContextProvider } from "./entity-editor/entity-editor-context"; import { FilePreviewSection } from "./entity-editor/file-preview-section"; +import { GraphSection } from "./entity-editor/graph-section"; import { HistorySection } from "./entity-editor/history-section"; import { LinkSection } from "./entity-editor/link-section"; import { IncomingLinksSection } from "./entity-editor/links-section/incoming-links-section"; @@ -173,6 +174,8 @@ export const EntityEditor = (props: EntityEditorProps) => { {tab === "history" ? ( + ) : tab === "graph" ? ( + ) : ( {isLinkEntity ? : } diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx new file mode 100644 index 00000000000..2ce148e2f3f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/graph-section.tsx @@ -0,0 +1,164 @@ +import { useQuery } from "@apollo/client"; +import { Box, useTheme } from "@mui/material"; +import { useCallback, useMemo } from "react"; + +import { getLatestEntityVertices, getRoots } from "@blockprotocol/graph/stdlib"; +import { type EntityId, splitEntityId } from "@blockprotocol/type-system"; +import { LoadingSpinner } from "@hashintel/design-system"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries"; +import { EntityGraphVisualizerV2 } from "../../graph-visualizer-2/entity-graph-visualizer"; +import { useSlideStack } from "../../slide-stack"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../../graphql/api-types.gen"; +import type { TraversalPath } from "@local/hash-graph-client"; + +/** + * Resolve the links into and out of the seed entity so its immediate neighbours come back as + * FRONTIER nodes (greyed-out until the user expands them). Mirrors the Graph view's traversal in + * the entities visualizer: both link directions, one hop. + */ +const graphViewTraversalPaths: TraversalPath[] = [ + { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, + { + edges: [ + { kind: "has-right-entity", direction: "incoming" }, + { kind: "has-left-entity", direction: "outgoing" }, + ], + }, +]; + +/** + * Embeds the graph visualizer (v2) seeded with a single entity -- the one being viewed. The seed is + * the only query ROOT; its one-hop link endpoints come back as FRONTIER nodes the user can expand + * from to explore outwards. + */ +export const GraphSection = ({ entityId }: { entityId: EntityId }) => { + const theme = useTheme(); + const { pushToSlideStack } = useSlideStack(); + + const [webId, entityUuid] = splitEntityId(entityId); + + const { data, loading } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + variables: { + request: { + filter: { + all: [ + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + ], + }, + traversalPaths: graphViewTraversalPaths, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }, + }); + + const subgraph = useMemo( + () => + data?.queryEntitySubgraph + ? deserializeQueryEntitySubgraphResponse(data.queryEntitySubgraph) + .subgraph + : undefined, + [data?.queryEntitySubgraph], + ); + + const entities = useMemo( + () => + subgraph + ? getLatestEntityVertices(subgraph).map((vertex) => vertex.inner) + : undefined, + [subgraph], + ); + + /** + * The seed is the only root; every other fetched entity (its link endpoints) is a frontier node. + * Deriving the root id from the subgraph (rather than reusing the page's `entityId`) keeps it + * matched to the fetched live entity regardless of any draft id on the page. + */ + const rootEntityIds = useMemo( + () => + subgraph + ? getRoots(subgraph).map((entity) => entity.metadata.recordId.entityId) + : undefined, + [subgraph], + ); + + const handleEntityClick = useCallback( + (clickedEntityId: EntityId) => { + pushToSlideStack({ kind: "entity", itemId: clickedEntityId }); + }, + [pushToSlideStack], + ); + + const handleOpenLinkTable = useCallback( + (linkEntityIds: readonly EntityId[]) => { + pushToSlideStack({ + kind: "linkTable", + itemId: `linkTable:${linkEntityIds[0] ?? "empty"}:${linkEntityIds.length}`, + linkEntityIds: [...linkEntityIds], + }); + }, + [pushToSlideStack], + ); + + return ( + ({ + position: "relative", + height: "min(calc(100vh - 320px), 900px)", + minHeight: 500, + border: 1, + borderColor: palette.gray[20], + borderRadius: "10px", + overflow: "hidden", + background: palette.common.white, + })} + > + {loading && !subgraph ? ( + + + + ) : ( + + } + onEntityClick={handleEntityClick} + onOpenLinkTable={handleOpenLinkTable} + /> + )} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx b/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx index 1ba9f9b3fb5..eadb884d5a6 100644 --- a/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/shared/entity-editor-tabs.tsx @@ -7,7 +7,7 @@ import { Tabs } from "../../../../shared/ui/tabs"; import type { PropsWithChildren } from "react"; -type EntityEditorTab = "overview" | "history"; +type EntityEditorTab = "overview" | "history" | "graph"; const defaultTab: EntityEditorTab = "overview"; @@ -125,6 +125,20 @@ export const EntityEditorTabs = ({ label="History" active={tab === "history"} /> + { + event.preventDefault(); + setTab("graph"); + } + : undefined + } + label="Graph" + active={tab === "graph"} + /> ); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/CONVENTIONS.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/CONVENTIONS.md new file mode 100644 index 00000000000..2cb935d17be --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/CONVENTIONS.md @@ -0,0 +1,51 @@ +# Graph Visualizer v2: Code Conventions + +## Types + +- **`type` over `interface`** unless the type genuinely needs declaration merging (it almost never does here). +- **`readonly` on all fields** by default. Mutable state is explicit and rare. +- **Branded primitives** for index/ID types (`EntityIdx`, `ClusterId`, etc.) via `brand.ts`. These prevent mixing structurally identical numbers/strings at compile time. +- **Types live where they're owned**, not in a central `types.ts`. The render layer owns render types. The worker protocol owns message types. Shared domain primitives (branded IDs, enums used across layers) get their own small focused files. + +## File organization + +- Each file has a single clear purpose. If a file accumulates unrelated concerns, split it. +- Types that are only used within one file stay in that file; extract only when shared. +- Barrel exports (`index.ts`) are kept minimal: re-export the public surface, nothing internal. + +## Where types belong + +| Type | Lives in | +| --------------------------------------------------------------- | ---------------------------------------------------------- | +| Branded primitives (`EntityIdx`, `EntityId`, `TypeSetKey`, ...) | `ids.ts` | +| `VizMode`, `LodMode`, `ClusterKind` | `ids.ts` (small, foundational, used everywhere) | +| `VizConfig`, `defaultVizConfig` | `config.ts` | +| `RenderCluster`, `RenderEntity`, `RenderEdge`, `RenderFrame` | `rendering/types.ts` | +| `MainToWorkerMessage`, `WorkerToMainMessage` | `clustering/worker-protocol.ts` | +| Worker-internal types (columnar storage, cluster tree, etc.) | `clustering/` subdirectory, co-located with implementation | +| Frontier types | `exploration/types.ts` | +| Interaction types (picking, drag, viewport) | `interaction/types.ts` | + +## Style + +- `interface` for all object shapes. `type` only for unions, intersections, and aliases. +- `readonly` on all fields by default. +- Prefer `const` assertions and literal types over enums. +- Discriminated unions for message types (the `type` field). +- No `any`. No `as` casts unless at a boundary with documented justification. +- No ASCII banners or decorative comment separators. +- Document non-obvious invariants in comments. Don't narrate structure the code already shows. +- Never comment what was removed or changed (`// removed X`, `// previously…`, `// used to…`, "no longer needed"). The code shows what IS, not what WAS — history is git's job. +- Never take shortcuts. When patches stop converging, STOP — step back and rework the module wholesale rather than patch-on-patch. A clean rework usually beats serial patches on a failing design, and a genuine shift can require fundamentally changing something; don't cling to the existing structure to dodge the rework. Correctness and performance are the bar above all. + +## Worker boundary + +- The worker owns all heavy state (entities, links, clusters, layout). +- The main thread receives **render payloads only**: compact, readonly, ready to feed to Deck.gl. +- Message types are the contract between threads. Changes to message types should be deliberate. +- Use `Float32Array` / transferable buffers for large position data. Do not serialize millions of objects. + +## Incremental development + +- Build the simplest thing that renders first. Add complexity only when the simpler version is working. +- Each layer (clustering, rendering, interaction, exploration) should be independently testable against its types. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/INTERACTION.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/INTERACTION.md new file mode 100644 index 00000000000..dfbe492cbfa --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/INTERACTION.md @@ -0,0 +1,188 @@ +# Presentation & Interaction — Design + +Companion to `LAYOUT-MODES.md` (what to lay out) and `MANIFESTO.md` (architecture + +hard-won lessons). This doc owns the **main-thread layer**: what we draw on top of the +computed layout, and how the user interacts with it. It is a plan, not yet built. + +## Guiding principle — the worker computes, the main thread presents + +The worker's job is **computation**: layout positions, degree→size, community +membership, bezier geometry. It streams the results into the SAB. The main thread's job +is **presentation + interaction**: labels, icons, tooltips, hover, selection, drag. + +Pure presentation needs **no computation** — a node's label is its type's +`labelProperty` _value_ ("Alice", not "Person"); its icon is its type's `icon` — and +**React already holds the entity and type data** (it ingests the entities and extracts +the type schemas to feed the worker in the first place). Shuffling that into the worker +just so it can ship it back is pointless. Therefore: + +- **Presentation + read-only interaction** (hover, click-to-inspect, select, labels, + icons, LOD) live **entirely on the main thread**, joining React's entity/type data + with the SAB (positions, radius). No worker round-trip. +- **Only interaction that changes the COMPUTATION** (drag/pin a node, expand a frontier + node) goes back to the worker — via the inbound side of a bidirectional SAB (see + "Talking back to the worker"). + +## The join key — record → entity in 4 bytes, not 64 + +The one thing the main thread lacks: given a rendered SAB record, **which entity is +it?** — so it can look up that entity's label / icon / properties. The full id is two +stitched UUIDs (~32 bytes binary, ~72 as text) — far too big to carry per record in the +hot SAB. + +**Resolution: two integers + a second SAB — never the id in the hot buffer.** + +1. **`EntityIdx` (u32) in the hot positions record** (`[x, y, radius, rgba, entityIdx]`, + +4 bytes) — maps a rendered record to the entity that owns it. The record order is the + LAYOUT's (sorted, then appended on absorb), NOT `EntityIdx` order, so the record must + carry `EntityIdx` explicitly. +2. **An `EntityIdx → EntityId` map SAB** — a dense, `EntityIdx`-ordered list of EntityIds + (2 UUIDs ≈ 32 bytes binary each), written by the worker (which owns interning), GROWN + in place + republished if it outgrows capacity, with the same atomic version + notify + as the positions SAB. **This IS the "SharedMap":** a SAB-backed array — single writer + (worker), readers (frontend), synchronized by the version bump. No library, no + message, no bidirectional sync. + +Frontend lookup: `record.entityIdx → idMapSAB[entityIdx] → EntityId → React's entity` +(React already holds entities by id), done only for visible / hovered records. + +Notes: confirm the EntityId shape (HASH = `webId` + `entityUuid` = 32 bytes; size up if a +`draftId` rides along). The worker parses the id to binary once on intern; the frontend +reconstructs the id string to key its map. Sizing: trivial at flat scale (≤2k → ≤64 KB); +at 3M-entity hierarchical scale the dense map is ~96 MB — fine as a shared worker-written +buffer, but scope it to rendered entities if that bites. A filter REPLACEMENT resets both +SABs together (MANIFESTO "filter change doesn't purge the old tree"). + +## Presentation layers (main thread) + +A continuous LOD ladder by apparent on-screen size — **never mode-gated, never +subtractive** ("we never remove information, we restructure it"). Importance (hub-ness) +shifts the thresholds, it doesn't gate them. + +0. **dot** — type colour, by-degree size. Straight from the SAB; always. +1. **type icon** at the node centre — the type's `icon`, once the node is big enough on + screen. Deck `IconLayer` from an atlas of the loaded types' icons. +2. **hub label** — adjacent to **hub nodes** (large radius = high degree, so the + threshold is just a radius cutoff on the SAB), showing the `labelProperty` value. + Hubs cross the label threshold first. +3. **always-on label** — adjacent to a node once it's big enough on screen: the + `labelProperty` value, plus the **type** where there's room. Nothing else always-on. + Hubs cross the threshold first; every node labels eventually. Everything beyond + label + type is HOVER-only (below). + +- **(future) filters / channel overlays** — mute/emphasise by a typed channel: + confidence, `actorType` (human / machine / AI), bi-temporal time. These change **what + is shown**, not the layout — main-thread visual filters, except where they change the + node SET (then a worker re-commit). + +### Always-on vs hover — RESOLVED + +**Always-on:** `labelProperty` value (+ type where there's room). **Everything else is +hover-only.** The hover card FOLLOWS the existing hash-frontend entity-display language — +reuse its type chips, confidence indicators, and property rows so it matches the table / +entity slideover (which click opens). Headline-first: + +- **Headline** — the `labelProperty` value (the entity's name). +- **Type** — title + `icon` (the existing type chip). +- **Confidence** — entity-level, and per-property where notable, via the existing + confidence indicator; shown only when < 1. +- **Provenance** — `actorType` (human / machine / AI) + source / creator. +- **Key properties** — a few salient property values as label : value rows; cap ~4–6, + overflow to the full window (on click). +- **Structure** — degree (N connections) and, in community-force, its community — + small / muted. + +(Exact property selection + layout is mine to finalise against the existing components +when building; this is the content contract.) + +## Interaction modes + +**Read-only — main thread** (Deck GPU picking, the dots layer is already `pickable`, + +React's data via the join key): + +- **Hover node → tooltip / detail panel** (the join above). _The foundational one._ +- **Hover edge → link detail** — edges are typed link entities; show `title` / + `inverse.title` + direction + confidence. +- **Click node → open the node window** — the entity detail slideover, exactly as the + entities TABLE opens it (reuse that component). Also selects the node + ego-highlights + (emphasise it and its neighbours/links, dim the rest, "what's connected to this?"). +- **Hover / click a BubbleSet → highlight the community** + a small summary (size, type + makeup). +- **Double-click → camera focus / zoom-to-fit** on a node or its community. +- **Search / jump-to-entity** — find by label, fly the camera, highlight. +- **(future)** box / lasso multi-select; right-click context menu (pin, hide, expand, + open in sidebar); keyboard nav (between connected nodes; escape to deselect; a11y). + +**Layout-affecting — round-trips to the worker** (inbound SAB): + +- **Drag a node** → pin it (FA2 `fixed`) at the dragged position; the field + re-energises and neighbours adjust; unpin on release. +- **Click a (greyed-out) frontier node → expand** — fetch its neighbours (they become + the new frontier), colour it in. (Spec §7; `worker/frontier.ts` is scaffolded.) + +**Click vs drag (both begin on a node).** Disambiguate by movement: a press→release with +no (or sub-threshold, ~4px) movement is a **click** → open the node window; a press that +moves past the threshold is a **drag** → take over from the canvas-pan controller and +move the node (pin + re-energise). Pan only when the press began on EMPTY space. Deck +already separates `onClick` (fires only when it wasn't a drag) from drag events, so this +mostly falls out of its event model: `onClick` opens the window; an `onDrag` whose +`onDragStart` picked a node moves that node (and suppresses pan) instead. + +**Foundational:** pan / zoom (Deck `OrthographicView` controller) — drives the LOD +ladder above; nothing else needs to know about it. + +**(future, distinct)** bi-temporal **time scrubber** — scrub `decisionTime` / +`transactionTime`; show the graph as-of a date or animate its growth. An +interaction/overlay in the "Semtype channels" roadmap. + +## Hierarchical-lod interactions + +Clusters, highways/feeders, AND revealed entities are all interactive here. The summaries +they show reuse the data the worker **already computes + ships in the StructureFrame** +(cluster label/count, edge aggregation), so these stay main-thread reads — consistent +with the split: the worker computes the aggregation, the main thread presents + interacts. + +**Cluster-level** (reads the StructureFrame's cluster/edge data — no extra round-trip): + +- **Pan / zoom drives the LOD cut** — clusters open/close by apparent size (already), + + camera re-centre on mode transition (#34). +- **Click a cluster → drill in / zoom-to-fit** + a cluster detail panel (its type + breakdown + count). +- **Hover a cluster → summary** — type, entity count, top sub-types. +- **Hover a highway or feeder → aggregated-connection detail** — the count, link types, + and direction bundled into it (its label, expanded); click → highlight the endpoints. +- _(deferred)_ drag a cluster to reposition — low priority; it fights the optimised + cluster solve (SMACOF + SA). + +**Entity-level** (inside an open leaf's entity-reveal — same `EntityIdx` join key as +flat): hover entity → tooltip card; **click entity → open node window**; hover an entity +edge → link detail. + +**The one rejected mode: dragging entities INSIDE the hierarchical layout** — d3-force + +`forcePortAttraction` owns those positions and a manual pin there has no clear value. +(Flat-tier node drag still stands.) + +**Shared:** search / jump-to-entity (drills the camera to reveal the target); frontier +expand (§7); breadcrumb / zoom-out. + +## Talking back to the worker — the inbound SAB + +Read-only interaction never touches the worker. Layout-affecting interaction uses a +**bidirectional SAB**: pair the layout's OUTBOUND `[version]` (positions; main thread +reads) with an INBOUND `[version][command…]` the worker watches (`Atomics.wait`). The +main thread writes a command — a dragged node's new position + pin flag, or a frontier +expansion — and bumps the inbound version; the worker applies it and **re-energises the +field** (the same scheduler re-kick a streamed node triggers: a settled layout ignores +its inputs until something re-energises it — see MANIFESTO `#scheduleFlatLouvainLinger` +/ the absorb re-kick). One atomic per interaction, no new message channel. + +## Build order (proposed) + +1. **Join key** (`index` in the SAB record, or current `nodeIds`) — unlocks everything. +2. **Hover → tooltip** — the foundational read-only interaction; proves the + record→entity join end to end. +3. **Readability layers** (#27): type icons, hub labels, full/hover labels, LOD — all + ride the same join key, all main-thread. +4. **Select + ego-highlight**, edge/BubbleSet hover, camera focus. +5. **Inbound SAB** → **drag/pin**, then **frontier expand** (§7). +6. **(later)** filters / channel overlays, time scrubber, multi-select, context menu. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/LAYOUT-MODES.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/LAYOUT-MODES.md new file mode 100644 index 00000000000..23361797734 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/LAYOUT-MODES.md @@ -0,0 +1,283 @@ +# Layout Modes — Design + +Concretises the spec's scale-adaptive strategy +(`../graph-visualizer/SPEC-graph-viz-v2.md` §"Scale-Adaptive Strategy") for the +work ahead. `hierarchical-lod` is built; `flat-force` + `community-force` are not +yet rendered. `recomputeMode()` already selects the mode and the StructureFrame +reports it, but the render path always builds the cluster tree — so the flat +modes are _picked but never drawn differently_. This doc is the plan to wire +them. + +## Guiding principle — read this first + +**Every decision here is driven by _what is useful to display_ at a given scale — +what we want the viewer to SEE. Performance is something we massage to serve +that; it is NEVER the reason for a design choice.** This underpins everything +below. + +Consequences: + +- The tiers, the grouping axis, and the zoom-LOD all answer **"what's valuable to + show here?"** — never "what's fast?". +- **Engine choices are interchangeable _means_, not design decisions.** WebCola + vs FA2, Louvain vs label-prop — whichever we pick, the _displayed result is the + same_. They change cost, not what's shown. So we use the best one we can afford + and swap reactively under load, and that swap is invisible to the design. + +## Why three tiers — what's valuable changes with scale + +There is no single layout that's legible at every scale; if there were we'd be +done. The driver is **what a viewer can usefully see**: + +- **Few entities** → the **link structure** is the value. You can actually read + "a links to b, together they form an order." Worth real effort to lay out so + that's visible. +- **More** → individual structure gets hard to read by eye; the value shifts to + **highlighting** the communities the structure forms. +- **Huge** → individual placement stops being legible AT ALL. You cannot SEE an + outlier in a sea of 3M nodes even with its position computed — it's an + information-density wall, a meaningless hairball (spec §"Problem"). The + representation **fundamentally shifts** to hierarchical semantic zoom (a type + axis + drill-down), the only legible view at that density. **This shift is + perceptual, not a compute compromise** — we couldn't make it _useful_ as a flat + view at any speed. + +So the three tiers are three different _ideas about what matters_, not three +speed budgets. (Thresholds are on loaded non-link entity count, with hysteresis; +the user may move them or opt in — exact numbers don't matter to the design.) + +| Mode | Scale (what's valuable) | Layout | Grouping visual | +| ------------------ | -------------------------------------------- | ------------------------------------------ | ---------------------------------- | +| `flat-force` | small — read the link structure | Louvain → SMACOF → FA2 | none (communities read spatially) | +| `community-force` | medium — highlight the communities | Louvain → SMACOF → FA2 | **BubbleSets** over subcommunities | +| `hierarchical-lod` | huge — flat view is a hairball; zoom instead | SMACOF (WebCola) + SA optimiser **(DONE)** | containment circles | + +**`flat-force` and `community-force` are ONE regime in disguise** — identical +placement; they differ only in **what's displayed** (community highlighting). The +genuine break is `hierarchical-lod`, for the perceptual reason above. + +## The flat-tier layout pipeline — a PIPELINE, not either/or + +FA2 only ever finds a **local** minimum. From a random start, a node that belongs +on the left can strand on the right with no downhill path back — a bad local +minimum. So we front-load intelligence to start near the **global** minimum: + +1. **Louvain over the link graph** (`graphology-communities-louvain`, seeded → + deterministic). Gives **cluster membership ONLY** — which node is in which + community. **No coordinates.** +2. **SMACOF** (stress majorisation over the link graph, via WebCola). Turns the + structure into **stress-minimised initial positions** — the seed Louvain + can't give. Deterministic; nothing random. +3. **FA2** refines (local spacing, hub-spreading) → a good local minimum near the + global one. + +Each step is distinct: **Louvain = clusters, SMACOF = stress-min seed, FA2 = +refinement.** That's "FA2 over SMACOF". The displayed result is the legible +link-structure layout — that's the goal; the steps are just how we reach it. + +### Engine choice within the pipeline (a means, not a tier) + +WebCola/SMACOF layouts are markedly better but O(N²); FA2 (Barnes-Hut) scales. +So below ~100 nodes WebCola does the whole layout, and above it we degrade to +SMACOF-seeded FA2. **This is purely how we stay affordable — the displayed +link-structure view is identical either way.** It is not a design tier and the +user never perceives it; tunable crossover. + +## Incremental loading — first-class + +Nodes arrive one after another; the layout must ABSORB a stream — never recompute +from scratch per node, never reshuffle/jump (the discipline the hierarchical tier +already follows: warm tracking, incremental updates). This is a _usefulness_ +requirement: a layout that jumps on every arrival is unreadable. + +- **New node** → seed it near its already-placed neighbours (or its community + centroid); the **warm FA2** absorbs it and re-settles locally. No global + restart. +- **Louvain + SMACOF run on the first substantial batch, then re-seed only on a + DEBOUNCED / significant-structure-change basis — NOT per node** (per-node + re-seed reshuffles, which is what makes it unreadable). +- If a chosen engine can't keep up as the set grows, **swap to a cheaper one** + (label-prop for Louvain, skip SMACOF, FA2-warm-only) — measured, never + pre-emptive. The displayed view is unchanged; only the cost is. +- Deterministic + stable: seeded RNG, warm-start from current positions, never + re-randomise. + +## The grouping axis — the reason the tiers _look_ different + +- **Flat tiers → link communities.** Louvain→SMACOF→FA2 reflects the real edge + communities **spatially**. `flat-force` shows them with type colour and nothing + else. At `community-force` scale they're hard to pick out by eye, so + **BubbleSets** shade the **subcommunities** to promote them. BubbleSets are over + (sub)communities, **NOT types** — types are already the node colours. +- **Large tier → type-set grouping.** Per the perceptual shift above, at hairball + density the type system is the legible clustering axis (the cluster tree we + have). No BubbleSets — containment circles already encode grouping. + +(`boundedLabelPropagation` in `community.ts` is a SEPARATE thing: in-cluster +sub-clustering of a 50k+ type-set within the large tier. Not the flat-tier +community detection.) + +## The engine split — which engine runs where + +Three engines coexist, one per regime — none replaces another: + +- **FA2** (`graphology-layout-forceatlas2`, already a dep) — the **port-free flat + tiers**. No containers → no ports → nothing for FA2's fixed force model to + conflict with. +- **d3-force + `forcePortAttraction`** — stays for the hierarchical + **entity-reveal** (dots inside a cluster chasing their ports). d3-force's + pluggable forces are the ONLY reason port-attraction works; FA2 has no + plug-point for a custom force. **Do not touch it.** +- **SMACOF (WebCola) + SA drawn-geometry optimiser** — stays for the **cluster + bubbles** (≤32 top-level, ≤96 sub-cluster). The SA pass minimises _actual_ + crossings / detours / edge-length. + +(SMACOF appears twice on purpose: the flat-tier _seed_, and the cluster layout +engine. Different uses, same majoriser.) + +## Display: zoom-driven level of detail (ALL tiers) + +Display is **never gated by mode** (A vs B). It's gated by **zoom + apparent +on-screen size + importance**, the SAME way across all three tiers. Governing +principle: **we never take information away — we restructure it.** + +Per-node LOD ladder, by apparent size (continuous): + +- tiny / far zoom → a **dot** (type colour); +- big enough → **+ centre icon** (if the type defines one) — pops in as soon as + there's room; +- bigger → **+ label beside** the node. + +**Importance shifts the thresholds, it doesn't gate them.** A hub crosses the +label threshold earlier (hubs read first); zoom in far enough and EVERY node +shows its label — including in `hierarchical-lod`. Nothing is permanently hidden. + +**Edge labels** follow the same rule — shown when the edge is prominent enough at +the current zoom, not gated on mode. Zoomed in enough → every edge's label. + +## Per-tier detail + +### `flat-force` — the fine-grained view + +- **Layout**: Louvain → SMACOF → FA2 (above). +- **Nodes**: individual entities; **colour by type**; **size by degree** (subtle); + **centre icon** if the type defines one. +- **Labels / icons**: zoom-driven LOD (see Display) — hubs label first, every + node labels when zoomed in enough, icons pop in when big enough. Beside the + node, not inside. +- **Edges**: individual — each link its own bezier (no highways/ports here). +- No grouping visual (communities read spatially). + +### `community-force` — medium + +- Same Louvain → SMACOF → FA2 layout / nodes / edges as `flat-force`. +- **+ BubbleSets** over the Louvain **subcommunities** (promote communities the + eye can't pick out at this scale). NOT over types. **This tier only.** (Exactly + which sets get a hull — communities vs finer subcommunities — is open.) +- Labels/icons via the same zoom-driven LOD — denser graph just means fewer cross + the threshold at a given zoom, not a different rule. + +### `hierarchical-lod` — large (clusters DONE) + +- Cluster bubbles via SMACOF + SA optimiser (current; do not change). +- **Entity-reveal** (deep zoom into a cluster): individual entities via the + EXISTING d3-force + `forcePortAttraction` (unchanged — FA2 can't host the port + force). Gains the same **centre icons** (render feature, engine-independent). +- **No BubbleSets** — containment circles already encode grouping. + +## Cross-cutting features + +- **Hub classification**: degree-based (incident-link count). Threshold TBD + (percentile / top-K / absolute). Drives label priority AND by-degree sizing. +- **Entity labels via `labelProperty`** (semtype): a node's label is the VALUE of + its type's `labelProperty` (a Person's name, an Order's number) — "Alice", not + "Person" — falling back to the type title when absent. This is what makes the + fine view readable ("Alice placed Order #4521" vs "a Person links to an Order"). +- **Type icons**: an entity shows its type's `icon` (if defined) in the centre. + EVERY view with individual entities (flat, community, hierarchical + entity-reveal). +- **Directional edge labels via `title` / `inverse`** (semtype): a link is a typed + entity with `title` and `inverse.title`. Forward lanes use `title`, reverse use + `inverse.title`, + arrowheads — meaningful directed edges. +- **Hierarchy-aware colour**: hue by root type, shade by specific subtype (uses + the same `allOf` inheritance the grouping does), so the type tree reads at a + glance instead of N arbitrary colours. +- **By-degree sizing**: subtle (e.g. `r = base · (1 + log(1+degree)·k)`). +- **Hover-detail labels**: on hover, show node details. ALL layers. **Deferred** + (with interactivity). +- **Interactivity** (click, expand, …): **deferred** until the layouts are done. + +## Semtype channels — roadmap (later overlays) + +A semtype entity carries far more than a type + links. Each of these is a +distinct "what's useful to display" lens, surfaced as a toggle/overlay/mode — +none change the layout: + +- **Confidence** — entities, values, AND links carry `confidence ∈ [0,1]`. + De-emphasise the uncertain (faint/dashed low-confidence edges, soft ring on + shaky entities). Surfaces data _quality_ — valuable as AI populates the graph. +- **Property-driven encoding** — let the user pick a typed property to drive a + channel ("size by revenue", "colour by date"). Semtype's typed values make the + property menu clean. Turns the graph into an exploration tool. +- **Bi-temporal time scrubber** — every entity + link has `decisionTime` / + `transactionTime`. Show the graph _as of_ a date, or animate growth. A flagship + semtype capability. +- **Provenance / origin overlay** — entities know `actorType` (user / machine / + ai) + sources. Human-vs-AI-vs-machine distinction shows trust/origin at a + glance. + +(Priority instinct: confidence + property-driven encoding change what the graph +is _for_ the most.) + +## Mode transitions (spec §4.4) — last + +- Upward (flat/community → hierarchical): animate entity positions → cluster + centroids; entities fade into bubbles. +- Downward: bubbles dissolve into entity positions; force from centroid seeds. + +## Building blocks + +Already present: + +- **FA2**: `graphology-layout-forceatlas2` (v1 dep). +- **SMACOF**: WebCola already drives stress majorisation for the cluster layout — + reuse it on the link graph for the flat-tier seed. +- **Link adjacency**: link store / CSR (spec §"Entity and Link Storage") — what + Louvain + SMACOF run over. +- **Port-aware entity force**: `entity-layout.ts` `createEntityLayout` (d3-force + + port-attraction) — stays as-is. +- **Render**: `ScatterplotLayer` (dots), `BezierSDFLayer` (edges). + +To add: **`graphology-communities-louvain`** (seeded Louvain), the **flat render +path** (bypass the cluster tree when `mode !== "hierarchical-lod"`), a +mode-switched StructureFrame, BubbleSets (`bubblesets-js` vs hand-rolled hull), +type-icon rendering, the hub classifier, by-degree sizing. + +## Build order + +1. **`flat-force` standalone** — add `graphology-communities-louvain`; flat render + path + Louvain → SMACOF → FA2 (incremental from the start) + by-degree size + + type colour + centre icons + hub-classifier → beside-labels + individual edges. +2. **`community-force`** — same layout + BubbleSets over subcommunities. +3. **Cross-mode transitions** (entities ↔ centroids). +4. **Icons on hierarchical entity-reveal** (drops in once icons exist). +5. **(later)** hover-detail labels + interactivity, across all layers. + +## Open questions + +- **Hub threshold** — percentile of degree, top-K, or absolute? (Default: top + ~10% by degree, with a floor so a tiny graph still labels something.) +- **Packages** — BubbleSets (`bubblesets-js`?) + subcommunity detection (which + package). Hulls are over **subcommunities** (resolved — not communities, not + types). +- **Re-seed trigger** — what counts as a "significant structure change" that + triggers a Louvain/SMACOF re-seed during incremental load (vs warm FA2 absorb)? +- **LOD thresholds** — apparent-size cutoffs for dot → icon → label, and how much + importance shifts them. + +Resolved: flat-tier layout = **Louvain (clusters) → SMACOF (stress-min seed) → +FA2 (refine)** (engine is a means — WebCola < ~100, FA2 above, same displayed +result); d3-force + port-attraction stays for entity-reveal; SMACOF + SA stays +for cluster bubbles; the large tier is a **perceptual** shift to type-set semantic +zoom (not a compute compromise); BubbleSets over (sub)communities in +`community-force` only. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/MANIFESTO.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/MANIFESTO.md new file mode 100644 index 00000000000..c7e714ecfac --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/MANIFESTO.md @@ -0,0 +1,414 @@ +# Manifesto: Graph Visualizer v2 + +Note from me to the next me. Read this before touching code in this directory. + +## What we're building + +A graph visualization for the HASH entities page handling 3M+ entities via hierarchical semantic zoom. Full spec: `../graph-visualizer/SPEC-graph-viz-v2.md`. The code is what matters now. + +## Testing locally (dev harness) + +`/dev-graph-visualizer` renders the visualizer against SYNTHETIC data with knobs (entity/type count, link density, root/frontier split, hub count) plus a **stream-incrementally** toggle that feeds entities in chunks -- reproducing the absorb / FA2-settle / hub-labels-during-load paths with no backend and no real entities. The fixture is seeded + deterministic: `dev-harness.tsx` + `dev-harness/generate-fixture.ts` (entities via `new HashEntity(...)`, hand-built nested type maps in `generate-fixture/build-type-maps.ts`). Use it to reproduce + verify the deferred issues below. + +## Architecture: the highway model + +Edges follow a highway metaphor that composes recursively: + +``` +Entity ──(feeder)──→ Cluster Port ──(feeder)──→ Container Port ═══(highway)═══→ Target Port +``` + +- **HIGHWAY**: ONE curved path between two cluster ports. Each link type AND direction is a LANE (parallel offset from center line, perpendicular to chord). A→B (700) and B→A (1) are separate lanes with independent widths. + +- **FEEDERS**: Inside an open container, curves run from each sub-cluster's boundary to the container's port. Feeder width is proportional to the sub-cluster's contribution. Feeders have per-type-and-direction colored lanes, matching the highway. + +- **RECURSIVE**: Sub-clusters have their own ports. Their feeders merge at the parent port, which connects to the highway. It's feeders all the way down until we reach individual entities. At each level, sibling contributions are aggregated by type and direction. + +- **CONTAINER SPLITTING**: When an edge crosses a container boundary, it's split into feeders (inside) and a shared highway (outside). Multiple children sharing the same container and external target merge into ONE highway. Both source-side and target-side containers are handled. + +- **DIRECTION NORMALIZATION**: Each child pair has its own `PairKey` sort order. `highwayDirection()` normalizes per-edge direction to "forward = source-side → target-side" relative to the highway, so merged lanes don't split incorrectly. + +## GPU rendering: BezierSDFLayer + +`gpu/bezier-sdf-layer.ts` renders pixel-perfect cubic Bezier curves on the GPU using Signed Distance Fields. No tessellation, no polyline joints. + +How it works: + +1. Each Bezier segment is an instanced screen-space quad covering the control point bounding box + padding +2. The vertex shader projects control points (world coords) to screen space via `project32` +3. The fragment shader computes exact distance to the cubic Bezier: brute-force search (25 samples) + Newton refinement (5 iterations) +4. SDF antialiasing via `smoothstep` + `fwidth` + +Ported to Deck.gl v9 / luma.gl v9: + +- `Model` and `Geometry` from `@luma.gl/engine` +- `ShaderModule` uniform block (`bezierUniforms`) for `uViewportSize` and `uBoundsPaddingPixels` +- `model.shaderInputs.setProps()` for uniforms, `model.draw(renderPass)` for drawing +- GLSL 300 ES (`in`/`out`, `fragColor` output) +- `"unorm8"` for color attributes (normalized in hardware, no `/255.0` in shader) +- `bufferLayout` from attribute manager + +GLSL reserved words that bite: `common` cannot be used as a variable name. + +**Performance reality**: 1366 Bezier SDF instances (490 entities × ~2.8 external links) causes visible GPU lag. Each instance runs 25 brute-force samples + 5 Newton iterations per pixel. Entity fan-out feeders may need a simpler rendering path (LineLayer or GL_LINES) rather than full SDF Bezier. + +## Collinear Bezier handling + +When two ports face each other directly, `cubicBetweenWaypoints` detects near-collinear control points and injects a deterministic perpendicular offset (MANIFESTO approach C). Key properties: + +- **Smooth ramp**: offset ramps to zero as natural curvature reaches the threshold (no snap) +- **Deterministic side**: FNV-1a hash of sorted cluster IDs (`pairBendKey`) picks bend direction (no flicker) +- **COLLINEAR_BEND_FRACTION = 0.15**: a gentle arc. Bowing applies ONLY to a single straight segment — `computeRawCurves`/`cubicBetweenWaypoints` take `bow`, and routed (B1) and boundary-crossing multi-waypoint paths pass `bow=false` (they already get their shape from waypoints; bowing each segment there made them wiggle). +- Threaded through `computeRawCurves` and all call sites via `bendKey` parameter + +Fan-out feeders use `straightToBezier` with `sCurve` parameter (±0.12 × chord) for gentle S-curves. Sign is stable per (entity, target) pair via `bendSignFromKey`. + +## Directional edge lanes + +Direction is tracked within each pair's aggregation, not by splitting `PairKey`: + +- `TypeAggregation` has `forwardCount` / `reverseCount`. "Forward" = link flows from the lower-sorted cluster ID to the higher one (matching PairKey sort order). +- `AggregatedVisualEdge.direction`: `"forward" | "reverse" | "both"`. A type with both directions emits two separate visual edges (two lanes). +- `highwayDirection(edge, nearChildId, side)`: normalizes child pair direction to highway orientation. +- `mergeLanes` and `mergeFeederTypes` key by `${typeKey}:${direction}`. + +## Merged port identity + +`Port.allNeighborIds: readonly ClusterId[]` carries every neighbor a port serves. For unmerged ports, single-element `[neighborId]`. For merged ports (angular sector collapse), all neighbors in the sector. The fan-out feeder builder iterates `allNeighborIds` when building the `portForTarget` map, so feeders to non-representative neighbors aren't dropped. + +## File map + +### Solid, reviewed + +- `brand.ts`, `config.ts`, `ids.ts`, `CONVENTIONS.md` +- `worker/interner.ts`, `worker/bitset.ts`, `worker/sorted-set.ts` +- `worker/column.ts` — `Column` with branded element type, `ColumnView` readonly views +- `geometry.ts` — `Circle` (readonly), `MutableCircle` (mutable), `Bbox` + +### Working + +- `worker/cluster-tree.ts` — `ClusterMembership` discriminated union, `ClusterTree` class +- `worker/community.ts` — CSR graph, bounded label propagation +- `worker/growable-buffer.ts` — the growable-SAB primitives (`sharedBufferAvailable`, `makeGrowableBuffer`, `growBuffer`) + `GrowableBuffer`: the abstract base that owns the grow-or-republish dance ONCE (grow in place via ES2024 `.grow`, else re-allocate + copy + re-bind views + fire a `RepublishHandler` so the main thread swaps buffers) plus the `[version:int32]` header + atomic `commit()`. Subclasses only fix header/record byte sizes and re-bind their own views in `bindRecordViews`. A `resizable: false` subclass uses FIXED allocations and always takes the re-allocate path — **mandatory for GPU-uploaded buffers**: WebGL `bufferSubData` rejects views over a resizable ArrayBuffer. +- `worker/position-buffer.ts` — `PositionBuffer`: the fixed-size SAB-backed position store (`[version:int32][x,y…:float32]`) for the cola/d3-force engines (fill `.positions`, `.commit()`); and `FlatGraphBuffer extends GrowableBuffer` (`resizable: false`, since its records are uploaded to the GPU): the interleaved flat-tier record store (`[version,count] + {x,y,radius,rgba,entityIdx}`), grown by re-allocation. +- `worker/entity-id-buffer.ts` — `EntityIdBuffer extends GrowableBuffer`: the `EntityIdx → EntityId` join-key map (48-byte records, webId+entityUuid+draftId), so the main thread turns a rendered record back into its entity without round-tripping the worker. `EntityStore` OWNS the writer and writes each EntityId the instant it interns the EntityIdx (it's just another column — always current, no mirror/sync pass); the worker publishes the buffer once (`ENTITY_ID_MAP`) + re-publishes on the rare re-allocation. `decodeEntityId(bytes, idx)` is the ONE decoder, shared by `readId` and the main-thread on-demand read. +- `worker/force-simulation.ts` — `ForceSimulation` (now ENTITY dots only) + the `LayoutSimulation` interface both engines implement. SAB-backed, time-budgeted ticking, settle at `SETTLE_ALPHA` (0.001 = d3's floor), `forceConfine`. (The MessageChannel tick scheduler lives in `index.ts`.) +- `worker/cluster-layout.ts` — `createClusterLayout`, powered by **WebCola** (`ClusterLayout implements LayoutSimulation`): stress majorisation (linked clusters near) + gradient-projection non-overlap (bubbles GUARANTEED disjoint). No force seed, no anchor. Driven manually (`SteppableLayout.runStep` = WebCola's protected `tick`) within a time budget, streamed to the SAB, re-centred on origin each step. SIZE-AWARE spacing: per-link ideal length & non-overlap padding scale with endpoint radii (root spreads, sub-clusters fill). +- `worker/entity-layout.ts` — `createEntityLayout`: compact dots (weak charge + `forceX/Y` + confinement), still d3-force. +- `worker/flat-layout.ts` — `createFlatLayout`: the small-N **flat-force** tier; drives WebCola `Descent` directly (recipe in Things to not repeat). `worker/community-layout.ts` — `createCommunityLayout`: the medium-N **community-force** tier; Louvain → bounded SMACOF seed → FA2 (its `iterate` driven directly), `communities` exposed for BubbleSets. `forceatlas2-iterate.d.ts` shims FA2's untyped `/iterate` subpath. Both `implements LayoutSimulation` and fill the SAME `FlatGraphBuffer`, so the render path is shared; `index.ts` selects the engine by `#mode`. `gpu/bubble-set-sdf-layer.ts` — `BubbleSetSDFLayer`: the crisp community-force BubbleSets (a per-community metaball isocontour, texture-backed; luma texture pattern in Things to not repeat). +- `worker/untangle.ts` — D1: seeded simulated-annealing crossing-reduction over WebCola's settled layout. SELF-CONTAINED objective (NO force/seed anchor): crossings + edge-through-node + overlap + linked-pairs-too-far + compactness; real temperature (energy-scale) + multi-restart best-of search. Run once per layout in `index.ts` `#untangleClusterLayout` on settle (≤ `UNTANGLE_MAX_NODES`). +- `worker/link-store.ts` — adjacency index, O(degree) lookups +- `worker/entity-store.ts` — interning + per-entity columnar storage; ALSO owns the `EntityIdBuffer` join map, writing each EntityId on intern (one more column). `worker/type-registry.ts`, `worker/type-set-store.ts` + +### Edge system + +- `worker/edge-aggregation.ts` — `CutIndex` with `collectEntityOwnership` recursion into children, `EdgeAggregator` with directional `forwardCount`/`reverseCount`, entity-mode transition detection, exported `makePairKey`. +- `worker/bubble-ports.ts` — `Port` with `allNeighborIds`, `computeAllPorts`, `slotPorts` (zoom-INDEPENDENT), `PortCache` keyed on positions + neighbor set (NOT zoom). Ports are fed HIGHWAY-level pairs by `index.ts` `#computePorts`, so a cluster's port toward a neighbor subtree is stable across that subtree opening/closing. +- `worker/edge-geometry.ts` — highway model. `highwayEndpoints`/`portsFor` (highway-level lookup), continuous chord-derived collinear bow, snug-packed parallel lanes (by width, not fixed spacing), per-lane labels at curve midpoints (rotated to the tangent, world-sized so they scale with zoom). `BezierSegmentSink` writes flat typed arrays. +- `gpu/bezier-sdf-layer.ts` — custom Deck.gl v9 layer for GPU-rendered Bezier curves, accepts binary attributes (interleaved `vec2` control points) +- `rendering/types.ts` — `StructureFrame` (identities + edge topology, on cut change); `PositionsFrame` (bounded cluster positions + `RenderBezierBuffers` + `RenderEdgeLabel[]`, per tick while settling). + +### Integration (rewritten in the rendering refactor) + +The single biggest change: the render pipeline is **split by update rate** so the +O(entities)/O(links) work runs only on topology change, never on a position tick. +See "Data flow" below. + +- `worker/index.ts` — `GraphWorker`. `commitStructure()` is the sole topology authority (cut + subdivide + aggregation + ports, emits `STRUCTURE_FRAME`); `#refreshPositions`/`#emitPositions` recompute only port+Bezier geometry from current positions (emits `PositionsFrame`); `#tickAllLayouts` ticks force sims and emits a PositionsFrame only when a CLUSTER layout moved. `handleViewport` commits only when the cut would change. Macro layout freezes at convergence. +- `worker/entry.ts` — thin dispatch. `commitStructure()` on ingest/register/embedding; `handleViewport()` on viewport. No frame cap, no `onFrameNeeded`. +- `worker/protocol.ts` — `STRUCTURE_FRAME` + `POSITIONS_FRAME` replace `RENDER_FRAME`. `LAYOUT_CREATED/DESTROYED` now carry only the entity SAB (radius/color/origin live in the structure frame). `BUFFER_REPUBLISHED` (with a discriminated `RepublishTarget`) swaps a held SAB on the rare re-allocation; fired by `GrowableBuffer`'s `RepublishHandler`, consumed by `use-graph-worker`'s `registerSab`. `ENTITY_ID_MAP` hands the main thread the EntityIdx→EntityId join-map SAB (once + re-sent on its own re-allocation). +- `worker/lod.ts` — `wouldChange()` probe + `applyVisibleCut()` commit, `#partition` helper. +- `use-graph-worker.ts` — holds the structure frame + positions frame in **refs**; a rAF-coalesced `positionVersion` and a `structureVersion` drive `updateTriggers`. SAB registry keyed by leaf id (`registerSab` adopts new/re-published buffers); `waitAsync` race fix; full cleanup on worker recreation. NO per-entity objects. Holds the `ENTITY_ID_MAP` buffer and exposes `resolveEntityId(layoutId, recordIndex)` — reads the record's `entityIdx` off the flat SAB, decodes it through the map — for picking (read on demand, no watcher). +- `graph-visualizer.tsx` — stable Deck.gl layers derived from refs: highway `BezierSDFLayer` (geometry from positions frame), one `ScatterplotLayer` per open leaf + straight `LineLayer`s for entity edges (both gathered from the same SAB → no tearing), `ScatterplotLayer` for cluster bubbles, `TextLayer`s for cluster + edge labels. Worker viewport notify is rAF-coalesced. `onClick` on a flat dot resolves its EntityId via `resolveEntityId(flatGraph.layoutId, info.index)` and calls `onEntityClick` (opens the entity, as clicking a table row does). + +## Data flow + +Four update rates, deliberately decoupled: + +1. **Viewport pan/zoom** — Deck.gl native, main thread. Worker is notified (rAF-coalesced) only to re-probe the LOD cut. +2. **Macro layout** (cluster bubbles) — a force sim that streams positions via `PositionsFrame` while settling, then FREEZES. Highways follow because their geometry is recomputed from the same positions each frame (bounded by the render budget). +3. **Micro layout** (entities in an open leaf) — force sim writing a `SharedArrayBuffer`; the main thread gathers visible dots + their edges from that SAB. Entity ticks do NOT emit a PositionsFrame (macro is frozen) and NEVER re-run aggregation. +4. **Topology/LOD** (open/close/ingest) — `commitStructure()` recomputes cut + CutIndex + aggregation + ports and emits a `STRUCTURE_FRAME`. Rare. + +Dual-purpose aggregates: inter-sibling link counts weight the force layout AND are drawn as highways. Same quantity, two views, at every level. + +## Selection focus dim + +Selecting a node recedes everything that isn't its ego, so the selection + neighbours pop. The set is GENERIC — `SET_HIGHLIGHT(entityIdxs)` (a selection's ego now, a path later); the worker dims the COMPLEMENT, the main thread decides the set. `dimColor` (desaturate toward grey + drop alpha) is the ONE formula (`dim-color.ts`), shared so dots, edges, and bubbles recede by exactly the same amount. What dims, and where: + +- **Entity dots** — worker, in place. Flat tier: `#writeFlatStyle` rewrites the flat SAB colours; leaf tier: `#writeLeafColors` rewrites each open leaf's interleaved SAB. Written on highlight change + leaf creation, NEVER per commit (per-commit was a zoom-stutter — see "Things to not repeat"). +- **Flat per-link beziers** — worker, `#buildFlatEdgeBeziers`: a link stays full only if BOTH endpoints are highlighted, else `dimColor`. +- **Cluster bubbles** — main thread, `clusterBubbleLayer.getFillColor`: a closed (depth-0) bubble recedes unless it's an ego cluster-target; open containers (depth > 0) stay (faint halos on the path to the selection). `#highlightTick` drives the updateTrigger; held until the ego query resolves so it lands in step with the dot dim, not a frame early. +- **Internal leaf edges + entity fan-out feeders** — main thread, `clusterEntityLayers`, per-edge: the SAME lookup as the dots (each endpoint's leaf-local index → `EntityIdx` via the leaf's `nodeIds`, in the highlight set?). An internal line is full only if BOTH endpoints are highlighted; a feeder dims with its source dot. A per-edge colour buffer is built when a highlight is active, uniform colour otherwise. The scene keeps `#highlightedEntities` mirroring what it sent the worker, so the lines dim in lockstep with the dots they connect. + +The overlay is just the selected node's ring (`selectionOverlayLayers`); ego neighbours are conveyed by the dim, not rings. + +**Deliberately NOT dimmed: highways (the aggregated cluster→cluster beziers).** They stay full under a selection — a CHOICE, not an oversight. Highways are AGGREGATED (no per-entity identity), so the only possible dim is coarse cluster-level (recede unless both endpoint clusters hold something highlighted), which is imprecise; and the SDF highway builder (`buildBezierSegments`) isn't worth threading focus through as a bolt-on. The highway dim comes for free once the hierarchical edges become worker-built per-edge beziers (#1 "Hierarchical entities SAB-direct") — then it's the same per-edge rule (both endpoints highlighted) as the flat tier. + +## Presentation + interaction (main thread) + +`INTERACTION.md` is the spec. The worker COMPUTES (layout, aggregation, bezier geometry, by-degree +size, community membership); the main thread PRESENTS + does read-only interaction by joining +React's entity/type data (which it already holds) with the SAB via the `EntityIdx` join key. The +worker never ships rich presentation data -- only IDENTITY. + +- **Hover / selection cards** -- `entity-hover-card.tsx` (a hovered entity, and the selected node + pinned with an Open button) and `highway-summary-card.tsx` (a hovered aggregated highway). All + follow ONE shell: icon tile + title + subtitle + stat rows. The card BODY is memoized on its + content; a positioning `
` rides a GPU transform so per-frame tracking never re-lays out the + body. Click-through except the Open button. +- **Tracking on pan / settle** -- the Scene re-emits each surface's CURRENT screen position every + frame (`#emitSelection`, `#emitHighwayHover`, `#emitEntityLabels`) by projecting a stored WORLD + anchor, so cards + labels follow the camera + the settling layout. A hover is anchored in world + space (not at the cursor), so a pan moves it WITH the highway / node. +- **Highway summary + links table** -- a LANE is single-type+direction by definition. The worker + ships the lane's type URL (identity) + count + direction + the union of its link EntityIdxs; + `#buildHighwayLanes` groups merged ribbons per (outer endpoints, typeSetIdx, direction) -- NEVER + endpoints-alone (that collapses distinct single-type lanes into a bogus "N link types"). The card + resolves the icon + title from the closed type schema (`getDisplayFieldsForClosedEntityType`, + which walks the type hierarchy) PER DIRECTION (`title` forward, `inverse.title` reverse -- "Has + Member" / "Member Of"). Click opens the FULL union in the slide-stack `EntitiesTable`. +- **Readability labels** -- only HUBS carry an always-on label. Hub-ness is decided in the SCENE + from each dot's by-degree RADIUS in the flat SAB (the worker's connectivity authority): the top + `HUB_LABEL_MAX_COUNT` dots above a degree-`HUB_LABEL_MIN_DEGREE` radius floor that also clear the + on-screen-size bar. The bridge's `resolveEntityLabel` just NAMES whatever it is asked about (prop + OR expanded entity) -- it does NOT decide hubs. Ordinary entities are HOVER-ONLY -- a name on every dot reads + cheap. Labels are HTML overlaid over the canvas (`entity-label-overlay.tsx`, in the hash design + language), NOT GPU text: the Scene rebuilds the label SET only on zoom/structure + (`#rebuildEntityLabelData` -- the one O(dots) scan + the only `resolveEntityLabel` call), then + projects + viewport-culls it each frame (`#emitEntityLabels`) and React renders the pills anchored + just below each dot's edge (radius scales with zoom). Hubs cross the size threshold sooner. +- **Picking is DECOUPLED from render order** (`#edgePickFor`). Layer order is RENDER order, so + hierarchical feeders/highways render UNDER the faint container bubbles (reading THROUGH them with + depth-opacity -- drawn over, they wash out). But an edge under a bubble must still WIN a + click/hover: when the topmost pick is a bubble, query the edges layer directly + (`deck.pickObject({ layerIds: ["edges"] })`). A dot -- drawn on top -- still wins over an edge. +- **Ego focus dim** is gated on a VERSION (`#highlightVersion` claimed then captured per query; a + later query bumps it, so a stale in-flight ego result drops). See "Things to not repeat". + +## Exception: property VALUES in the worker (distinctive-feature naming) + +The rule above is "worker holds IDENTITY, main thread presents" -- the worker never sees rich +entity/type data. Distinctive-feature cluster naming is the ONE deliberate exception, and it +earns it. Subdivision produces groups with no inherent name -- embedding (kmeans) groups +("Similar group n") AND the non-embedding grouping fallback ("Group n": link-signature +`community` buckets + `entity-bucket` chunks). The only way to name them is to scan every +member and find the signature a group shares but its siblings don't (coverage x idf, the same +shape as the type labeler), across a UNIFIED feature space: exact `(property = value)` pairs, +numeric/date RANGES, and LINK target types ("what they link to" -- the only thing that +separates entities whose own properties are uniform, like batches). That scan is +O(members x features) per subdivision -- exactly the kind of work the worker exists to take OFF +the main thread and onto its scheduler. Doing it on the main thread (where the rich `properties` +already live) would re-introduce the contention the whole worker split exists to avoid, so the +VALUES come to the work, not the work to the values. The namer is REUSED across both paths +(`#scheduleDistinctiveFeatureNaming`) but stays separate from the type-set (`distinctiveLabel`) +and community (`labelAllCommunities`) labelers -- it only sets text where it finds a confident +signature, so it upgrades a placeholder/community name and leaves everything else untouched. + +What crosses the seam, kept as narrow as the rule it bends: + +- `IngestEntity.properties` ships a "scalars-plus" reduction of each NODE entity's property object + -- strings/numbers/booleans become a value feature, arrays collapse to a count, nested objects + to a presence token; links ship nothing. `PropertyStore` (`worker/stores/property-store.ts`) + interns each `(baseUrl, formatted-value)` to a small integer the instant it ingests, and an + entity then holds only its sorted feature indices -- NOT the rich object. The worker gains a + by-value INDEX, not a second copy of the entity store. +- Numbers and ISO dates ALSO keep their RAW value per entity (keyed by an interned base URL). + This widens the exception past interned-index-only, and it must: a range like "Quantity + 100–500" is bucketed from the LIVE distribution of a subdivision's siblings (the namer puts + bucket edges at the midpoints between the clusters' medians), so "low" vs "high" is relative + and cannot be pre-interned the way an exact value is. It stays a flat `Float64Array` of scalars + alongside its key indices -- still a by-value index, never the rich object. +- LINK/target-type features need NO new seam: they read the link store + type titles the worker + already holds. `cluster-feature-source.ts` walks a member's links to the PRIMARY type of the + entity at the other end ("→ Material"). The target's SUB-cluster would be sharper, but those + clusters don't exist at naming time; its type is the coarse proxy available now. +- Property display TITLES ride a `PropertySchemaEntry[]` (`baseUrl -> title`) on init/register, + the property-type analogue of the type-identity schema the worker already takes -- so a label + reads "Destination = ..." not a raw base URL. Still schema, still identity-shaped. + +The naming itself (`worker/hierarchy/distinctive-cluster-label.ts`, fed by +`cluster-feature-source.ts`) runs on the job scheduler (deferred behind the placeholder commit, +never on the layout/commit hot path), is bounded to a deterministic ~5k-member sample per +cluster so cost stays flat on huge groups, and its ONLY output back to the render path is the +label TEXT -- the worker still ships no rich presentation data, just a computed string. If you +ever want MORE entity data in the worker for some other feature, this is the bar: it must reduce +to a by-value index (interned, or a flat scalar array), it must be work that genuinely cannot +live on the main thread, and the output must be identity/text -- never a round-trip of +presentation data the main thread already holds. + +## Subagent swarm harness + +`audit/run-audit.sh` launches `claude -p` agents in parallel git worktrees. Each agent gets a focused prompt from `audit/prompts/.md`, read+write tools, and produces analysis (`audit/results/.md`) + diff (`audit/results/.diff`). + +Worktrees live at `/Users/bmahmoud/projects/worktrees/hash/graph-visualizer-review//`. + +The script stages all files (`git add -A`) before collecting the diff, so new files are captured. + +### Agents run this session + +**Round 1 (correctness audit):** + +- `threading` — SAB watchAsync race, worker recreation cleanup +- `coordinates` — PortCache geometry keying, entity dot offset from render frame +- `edge-system` — Highway double-counting, entity-mode transition, rollup ownership, PortCache (independent finding) +- `force-layout` — forceEdgeAvoid gated to ≤64 nodes +- `gpu-shader` — Confirmed shader correct; Bezier-to-line is upstream collinear issue +- `dead-code` — Removed ~700 lines dead tessellated pipeline, dead protocol messages, orphaned types +- `init-race` — `wouldChange` probe, `buildRenderFrame` as sole LOD authority, entity layout warmup (warmup later removed for entity layouts due to lag) + +**Round 2 (features + targeted fixes):** + +- `bezier-collinear` — Perpendicular offset injection, S-curve fan-out +- `merged-port-feeders` — `allNeighborIds` on Port +- `directed-lanes` — Forward/reverse counts, directional lane merging +- `flat-arrays` — In progress: restructure to flat typed arrays for GPU performance + +## Status after the rendering refactor + +### Fixed + +- **OOM on zoom-in** — gone. Root cause was per-tick `buildRenderFrame` (full cut + CutIndex over ALL entities + aggregation + Bezier) funneled through React state, plus one JS object per entity rebuilt every tick. Now: topology work runs only in `commitStructure` (on cut change); positions stream via SAB/`PositionsFrame`; no per-entity objects ever. +- **30fps cap** — removed. It was a band-aid; the bleed it covered is gone. +- **Layers recreated every frame / GPU state thrash** — layers are derived from refs keyed on `structureVersion`/`positionVersion`; position ticks only bump `updateTriggers`/feed fresh bounded binary buffers. +- **Position changes ≠ edge changes** — entity ticks no longer recompute edge topology; aggregation is cached from the last commit. +- **Entity fan-out tearing** — dots and their edges are gathered from the SAME SAB on the same frame; they cannot drift apart. +- **Ports on the wrong side, then jump** — macro layout freezes between LOD changes, so ports are computed once against settled positions. +- **Edge reshape / bend flip on zoom** — the collinear bow is derived from chord direction (continuous), not an FNV hash of endpoint ids (which flipped when a container opened). +- **Subdivision/LOD commit firing off the physics clock** — `#tickAllLayouts` never recomputes the cut or subdivides; only `commitStructure` does. +- **Deterministic entity seeding** — phyllotaxis replaces `Math.random`, so re-opening a leaf is stable. +- **Config validation** (spec §10) runs on worker init. +- **Ports reshuffle when opening an unrelated container** — ports computed at the HIGHWAY (outermost-rendered-container) level, so a cluster's neighbor set + slotting is stable whether a neighbor's container is open or closed. +- **Ports re-slot on zoom** — slotting is zoom-independent; the PortCache key dropped zoom. +- **Entity fan-out follows the port** — fan-out targets the leaf's boundary toward the same highway port the feeders use (chains into feeder→highway). +- **Single-frame commit flicker** — `STRUCTURE_FRAME` + its paired `POSITIONS_FRAME` applied atomically (the structureVersion bump is deferred to the positions message). +- **Picking jerk on click-zoom** — view state uses `zoomX/zoomY` only (no scalar `zoom` mixing); interpolator animates `["target","zoomX","zoomY"]`. +- **Sub-clusters ringing the container edge** — confined layouts use an inward spring (FILL); only the top-level uses `forceCenter` (SPREAD). +- **Freezing before it looks good** — settle floor lowered to d3's `0.001`, so the low-energy collision pass relaxes the last overlaps (the "nicer when left alone" effect, now default). +- **Lanes spread/overlap at ports** — parallel lanes packed snug by width (`LANE_GAP_PX`), not fixed spacing. +- **Edge labels** — one per lane (type + count), riding the lane: rotated to the tangent, world-sized so they scale with zoom, on-screen-length culled. +- **Depth-≥2 fan-out exit drifted off its port** — the entity fan-out aimed its exit straight at the OUTERMOST container port (`hp.a`), but the feeder leaves a bucket toward its NEAREST enclosing container's boundary first. With an intermediate container (depth ≥ 2) those diverge by a position-dependent few degrees — the "4 vs 5 o'clock that sometimes fits". Fixed by aiming the exit at the feeder's first waypoint via the shared `containerBoundaryWaypoint`. _(Geometry locked in `entity-fanout-exit.test.ts`.)_ NOTE: dots intentionally settle a bit INSIDE the rim (centre spring + port pull blend) — that inward bias is desired, not a bug. +- **World positions stale through settled intermediates** — the old propagation only fired when a layout ticked, so it missed commit ticks where the macro was already settled. Replaced by authoritative `#syncWorldPositions` (top-down recompose before every emit); entity layouts are also kept warm while the macro moves so dots track drift. _(Composition proven in `world-positions.test.ts`.)_ Correctness improvements, but NOT the depth-2 port offset — that was the fan-out exit geometry (above). + +### Layout quality (B1 + D1, implemented) + +- **Layout = WebCola** (`cluster-layout.ts`): stress + non-overlap solves "linked-near + disjoint" DIRECTLY, replacing d3-force for clusters (which only proxied it and needed an anchor crutch). SMACOF/stress majorisation is what WebCola _is_, so the hand-rolled `stress.ts` was dropped. +- **Ports as constraints** (`ClusterLayout.setPortAnchors`, `index.ts` `#applyPortConstraints`): each opened container adds a FIXED rim anchor toward every external neighbour and links the children whose edges cross it, so WebCola's stress sorts those children toward their real connections (feeders leave the container without crossing). Computed from the boundary aggregate edges; applied once while the sub-cluster layout is still running. `handleDisconnected` is disabled for that re-run so component packing can't relocate the fixed anchors; anchors are appended AFTER the children so the SAB output (children only) is unaffected. Pull weight ∝ `1 + log2(1 + edge count)`. As the macro layout moves, `#updateAnchorTracking` re-aims the anchors IN PLACE (`updateAnchorPositions` — no re-run, no emit; WebCola re-reads a fixed node's locked `px`/`py` each step), so opened sub-clusters track their ports live. **Entities** use the analogous idea as a d3 FORCE (`forcePortAttraction`): each dot is pulled toward its owner's port target via a live target array, BLENDED with a gentle centre spring (`forceX/Y(0)`) that stays on — so a targeted dot settles a bit INSIDE the rim (≈80% out), which keeps dots legible and lets a multi-port dot sit between its ports (the blend is intended, NOT a bug). The fan-out endpoints (where the dots' feeders converge) AND the targets are POSITIONAL — `#buildEntityFanOut` refills them every tick and they ride `PositionsFrame.entityFanOut` — so dots and lines track their ports with NO structure re-emit. The exit aims at the FEEDER's first waypoint (the nearest enclosing container's boundary via `containerBoundaryWaypoint`), NOT the outermost port directly, so it chains seamlessly into feeder→highway at every depth (at depth ≥ 2 an intermediate container makes those diverge). While the macro is still moving the ports drift continuously, so each dot layout is kept WARM (`resume()` each tick while any cluster layout runs) and only settles once the macro does. +- **Sub-cluster confinement** (`ClusterLayout.#fitWithin`): WebCola's stress spread is wider than circle-packing, so children would overflow the parent bubble; after re-centring, a confined layout is clamped into the circle with overlaps relaxed. +- **B1 routing** (`edge-geometry.ts` `routeAround`): MULTI-PASS — bends around every bubble the segment actually ENTERS (not merely grazes its clearance ring — that over-triggers and zig-zags through dense fields), re-testing the whole polyline after each detour, then pulls the polyline TAUT (drops any waypoint whose removal re-introduces no clip). Obstacles are ALL visible bubbles (opened containers included); a bubble ENCLOSING an endpoint is exempt (geometric `containsPoint`). Routed segments use `bow=false` and proper Catmull-Rom tangents (END handle points BACKWARD so the cubic can't overshoot/loop). +- **D1 untangle** (`untangle.ts`): real SA crossing-reduction over WebCola's settled layout, ≤ `UNTANGLE_MAX_NODES`. NO anchor — it optimises the true objective over the full space (multi-restart), with WebCola's result as one warm start. Then a **2-opt position-swap polish** (≤ `TWO_OPT_MAX_NODES`) exchanges pairs of cluster positions, keeping any swap that lowers total energy — the move that actually UN-CROSSES a layout (single-node nudges can only relax locally). Crossing-min on a free 2D layout is NP-hard, so SA-restarts + 2-opt is effectively optimal at the dozen-node top level, not provably global (exact ILP is a layered-drawing tool; it doesn't apply to continuous positions). +- The architecture (user-confirmed): **WebCola (stress + non-overlap, every level) → untangle (crossing polish, small N) → routing (always)**. Multi-level coarsening is reserved for flat-force/community-force (one flat graph up to ~1k nodes). +- **Top-level optimiser** (`top-level-layout.ts`, ROOT only): the top level is solved DIRECTLY rather than by stress + a crossings polish that fight each other, because the outside is the hard case — unconfined, hugely size-disparate (a 5 500-node bubble beside a 76-node one), edges that must get past those obstacles, AND the overview every deeper layout inherits. WebCola gives the stress + non-overlap SEED; then a simulated-annealing search (jitter + position swaps + restarts, deterministic per cluster-id seed) minimises ONE objective on the DRAWN geometry: crossings + edge-through-bubble (detours) + edge length + non-overlap + neighbour spread, with edges scored RIM-to-RIM (where they attach — a port), so it minimises what's actually drawn. (The old pipeline optimised node CENTRES, placed ports afterward, then routed — so nothing ever minimised the result; that was the "ports can't move during the later passes".) The "mitosis" neighbour-spread is now a TERM in this objective, not a separate seed. Runs once on settle, gated ≤ `TOP_LEVEL_MAX_NODES`, via the shared `#polishSettledLayout` — called from the tick loop AND from `#ensureChildrenLayout` for layouts that settle during their warm-up (the root almost always does; without that second call the optimiser/untangle never ran at all). Sub-clusters keep WebCola + port anchors + untangle (confined; the anchors shape them). (`top-level-layout.test.ts`: an edge through a huge obstacle clears, detour 1.0 → 0; crossing edges uncross, 1 → 0. In-app: energy 134 → 8.6, detour 1.26 → 0 on the real top level.) + - **Incremental stability** (the top level was ERRATIC — every ingest re-derived a different arrangement): the optimiser is a near-optimal but DISCONTINUOUS function of its input, so re-running the global search on each structural change reshuffled everything for a one-node delta — and family rollups are destroyed/recreated on every hierarchy rebuild, so they came back as fresh circles and lost their place too, scrambling even the seed. Fix: the previous positions are persisted by cluster-id (`#topLevelPositions`, survives layout recreation AND the family-node rebuild) and passed back to `optimizeTopLevel` as per-node ANCHORS. With anchors the search becomes a LOCAL refine: an inertia term (`ANCHOR_WEIGHT`·(displacement / meanRadius)², calibrated against `CROSS_WEIGHT` so a bubble won't travel ~2 mean-radii unless doing so clears > ~1.5 crossings) + small steps + one pass + rare swaps. Existing bubbles keep their place; only genuinely-new bubbles (null anchor) are placed freely; the cold FIRST build (no anchors) is byte-for-byte unchanged. The recreated root layout also WARM-SEEDS WebCola from the same persisted positions (not the ring / near-sibling seed), so the stress stage starts continuous too. The anchor cloud is mean-aligned to the seed before scoring, so inertia penalises RELATIVE rearrangement only (the layout is re-centred on its centroid, which shifts when a node is added — a uniform translation we must not fight). (`top-level-layout.test.ts`: a poor seed moves 345 px cold vs 38 px anchored.) + - **Viewport-weighted anchoring** (`Anchor.weight`, `viewportAnchorWeight`): the anchor strength is per-node, falling off (Gaussian) with the bubble's distance from the viewport CENTRE, with the falloff radius = the viewport's visible half-diagonal in world units (`hypot(w,h)/2 / 2**zoom`) so it scales with zoom. What the user is looking at is pinned hard (weight ~1); off-screen bubbles relax to a small floor (`VIEWPORT_ANCHOR_FLOOR`) so they're free to reflow where it doesn't disturb the mental map. The frame alignment is WEIGHTED by the same factor, so the central nodes (not the freely-drifting periphery) define the "stay put" frame. Zoomed in → only the few central bubbles are held; zoomed out → most of the graph is. No viewport yet → uniform weight 1 (the plain anchored refine). The pin STRENGTH also scales with zoom (`zoomStrength = max(1, 2**zoom)`, multiplying the centrality term only): inertia is penalised in world units but the user sees screen units (`2**zoom` px/world), so without this a bubble you've zoomed right into visibly drifts even though it barely moved in the world — the amplification holds its SCREEN movement ~constant across zoom, while off-screen bubbles (falloff ≈ 0) stay at the floor and keep reflowing. + - **Anchors that yield to growth** (`relaxOverlaps`, `layoutNeedsRebuild`): an anchor pins a bubble to its PREVIOUS position, which becomes infeasible when the bubble grows — a cluster that jumps 70 → 2000 entities (~5× radius) pinned at the viewport centre overlaps its equally-pinned neighbours. The anchored search clears most of that, but anchored to overlapping positions it can leave a residual sliver it won't close (inertia is quadratic, overlap only linear, so the pull-back holds bubbles short of fully separating). Two fixes: (1) a pure size change keeps the child count, so the reuse guard (`layoutNeedsRebuild`) rebuilds when a freshly-sized child now OVERLAPS a neighbour at its frozen position (else a settled layout keeps its old radii and never re-solves). The trigger is the overlap itself, NOT a "radius grew > X%" proxy: a percentage threshold rebuilds harmless growth that still has slack (churn — every ingest re-running WebCola is exactly the erratic reflow we removed) yet misses growth that overlaps in a tight spot; shrink never rebuilds. The rebuild warm-seeds from the persisted positions and re-runs WebCola's hard non-overlap with the new radii. (2) after the anchored search, `relaxOverlaps` deterministically pushes any remaining overlaps apart, distributing each pair's separation by anchor weight as a MASS — a pinned central bubble barely moves while lighter/off-screen neighbours yield — so "nothing near the viewport may move" becomes "central bubbles move only as much as growth forces, preferring to displace lighter neighbours". A no-op when nothing overlaps, so a stable layout is never disturbed; cold builds resolve overlap in the global search and skip it. + +### Remaining / deferred + +_Ports-as-constraints (cluster anchors, sub-cluster LIVE anchor tracking, and the entity port-attraction force) and sub-cluster confinement are now done (see Layout quality). The items below are the remaining queue, in roughly this order._ + +1. **(TOP) Hierarchical entities SAB-direct** — the hierarchical entity dots/edges still GATHER from the SAB into fresh world-coord arrays each frame (adding the leaf origin), instead of letting Deck read the SAB directly. Fix: per-leaf `modelMatrix = translate(leafWorldX, leafWorldY)` on the dots `ScatterplotLayer` + a direct SAB view (no gather); entity edges (internal + fan-out) become worker-built bezier buffers like the flat tier, not main-thread `LineLayer` gathers. This is the HARDENED path (the bug list below lives here) — change carefully, keep green. The flat tier already follows this pattern (one interleaved `FlatGraphBuffer`, worker-built typed beziers, presentation is a thin pass-through). +2. **flat-force / community-force modes** — flat render path DONE + verified in-app: `commitStructure` branches on mode; the whole entity set lays out by **driving cola's `Descent` solver DIRECTLY** (NOT its `Layout` class, whose `start()` runs the phases synchronously and can't stream). Staged across the event-queue scheduler, every step written to the SAB: Phase A unconstrained stress → convergence, then `separateGraphs`/`applyPacking` for disconnected components, then a FIXED-budget VPSC overlap phase; `jaccardLinkLengths` spreading. cola OWNS every position (never perturb). ALL per-node GPU data in one interleaved `FlatGraphBuffer` SAB (`[version][count][x,y,radius,rgba]`, read via stride/offset); per-link **typed** beziers (world width) via the shared `BezierSDFLayer`; hierarchy-aware colour + by-degree size. The cola `Descent` driver is the **flat-force (small-N) path only** — it's O(N²) (all-pairs distance matrix + per-step stress), fine for a couple hundred nodes but too heavy as N climbs toward ~1000. **`community-force` is now BUILT + verified beautifully in-app** (`community-layout.ts`): the **Louvain → SMACOF-seed → FA2 pipeline (#26)** — FA2 (`graphology-layout-forceatlas2`, Barnes-Hut O(N log N)) driven via its `iterate` primitive DIRECTLY (one step per scheduler tick over flat matrices we own, NOT the blocking batch `forceAtlas2(graph,n)`); seeded by a bounded cola/SMACOF stress pass; communities from seeded Louvain (`graphology-communities-louvain`) exposed via `LayoutSimulation.communities`. `inferSettings` + forced `adjustSizes` (anti-overlap on the exact path); FA2 `gravity` holds disconnected components together so there is **no packing step and no bounding box** (cola's `applyPacking(desired_ratio=1)` squares the disconnected components into a box; FA2 doesn't). Same interleaved `FlatGraphBuffer` SAB + bezier path as flat-force, so the render is shared; `index.ts` `#rebuildFlatLayout` picks the engine by `#mode` (the `#flatLayoutMode` guard forces a rebuild when crossing flat↔community). Crossover at `flatLayoutExitNodes`; engine is a MEANS (same displayed result). + +**REMAINING for the flat tier (roughly in order):** + +- **True incremental — growable SAB + warm FA2 absorb + periodic re-seed (#36): BUILT.** community-force ABSORBS streamed nodes incrementally. The SAB is over-allocated (`flatCapacityFor`); `CommunityLayout.absorb(newNodes, edges)` appends new nodes at the END (existing record indices keep their slot), rebuilds edge topology from the PRESERVED positions (`#rebuildMatrices` reads each node's mirrored x/y — existing stay where they settled, new start at their seed; velocities reset, FA2 re-derives forces), and continues. `#commitFlat` decides absorb-vs-rebuild: absorb for community-force additions (same engine, no removal); rebuild ONLY on first build, mode switch, the cola tier (no `absorb` — can't grow a fixed-N×N `Descent`), or a removal. Three regimes inside the absorb path: + 1. **Fits capacity** → records append in place; `#writeFlatStyle` bumps count + version in ONE atomic — **no realloc, no `LAYOUT_CREATED`** (the main thread's SAB watcher + structure-frame `count` pick it up). + 2. **Overflows capacity** → `#absorbFlatNodes` calls `this.#flatBuffer.ensureCapacity()` (the shared `GrowableBuffer` primitive), which **RE-ALLOCATES** a bigger buffer and copies the records across. The flat SAB is **non-resizable** — its bytes are uploaded to the GPU each frame and WebGL rejects views over a resizable ArrayBuffer (`bufferSubData` throws), so it can't use ES2024 `.grow` in place; only the CPU-read-only EntityId map does. The layout holds the SAME `FlatGraphBuffer` instance, so its warm FA2 state rides across (only the instance's `raw` is swapped under it — FA2's matrices are untouched); `count` is preserved by the copy. The buffer's injected `RepublishHandler` (`#republishFlatBuffer`) fires `BUFFER_REPUBLISHED`, and the main thread's `registerSab` swaps to the new (still fixed, uploadable) buffer + re-watches. **No `setBuffer`, no `LAYOUT_DESTROYED`/`CREATED`, no cold restart.** + 3. **Grown by `LOUVAIN_REFRESH_GROWTH_FRACTION`** (floor `LOUVAIN_REFRESH_MIN_NEW_NODES`), OR ingests go quiet for `FLAT_LOUVAIN_LINGER_MS` (a trailing-debounce linger, `#scheduleFlatLouvainLinger`, so the BubbleSets reflect the SETTLED graph) → **refresh Louvain ONLY** (community membership for the BubbleSets). **No SMACOF re-seed**: dense stress is O(N²), so it runs ONCE at the initial build; FA2 is the incremental global engine (new nodes seeded beside neighbours via the link store, it keeps tightening). So re-globalise = Louvain only (leading growth trigger + trailing linger), no per-re-globalise O(N²). + + cola (flat-force) rebuilds warm-seeded each batch — no `absorb`. NOTE: since growth is now in place, the main thread keeps the SAME `EntitySab` (and its `nodeIds`) across an absorb; rendering is unaffected (dots read by `count`, edges worker-built), and picking joins through the in-record `entityIdx` + the EntityId map (#38), not the per-record `nodeIds` list. + +- **BubbleSets over Louvain communities (community-force): BUILT + verified in-app** (`gpu/bubble-set-sdf-layer.ts`). One CRISP metaball isocontour per community: an instanced quad per community; the fragment shader sums a Wyvill kernel over THAT community's node centres (read from an `rg32float` positions texture via `texelFetch` + the per-instance `[offset, count]`) and THRESHOLDS the field (`smoothstep` + `fwidth` AA) → a smooth, hard-edged hull, coloured by community, distinct per community (each quad sums only its OWN nodes → no cross-merge), single pass (no FBO). `buildBubbles` (in `graph-visualizer.tsx`) groups nodes by community, keeps only size ≥ `MIN_COMMUNITY_SIZE` (a pair/singleton needs no hull — bubbling every one is mud), gathers their centres into the texture + builds the per-community instances; the layer owns the texture. Drawn BEHIND dots/edges (`unshift`), community-force only. Tunables: `fieldRadius`, `isoThreshold`, `MIN_COMMUNITY_SIZE`. Over (sub)communities, NEVER types. OPEN: a brighter iso-edge rim (drawn outline) is an easy shader follow-up; finer SUBcommunity hulls (a 2nd Louvain pass) if wanted. +- **Type icons** — SHIPPED, both tiers. The type's `icon` is drawn at the node centre via a deck `IconLayer` over an `IconAtlas` (GPU, scales) -- chosen over an HTML overlay because there can be very many. The atlas rasterises mixed formats (emoji synchronously; image URLs async, bumping the atlas version + re-pushing on load); FontAwesome/ReactElement icons have no atlas entry, so those dots simply show none. The bridge's `resolveEntityIcon` maps an entity to its atlas KEY (the same display field the hover card uses). Soft-LOD: the icon is a fraction of the dot diameter and fades in only once the dot has screen presence. `typeIconLayer` serves the flat tier (one whole-graph SAB, per-node radius); `leafTypeIconLayers` serves the hierarchical tier (one layer per open leaf, uniform radius, all-or-nothing fade). The Scene scans both tiers' SABs in one `#rebuildEntityIconData` pass (the only O(dots) icon-resolution scan, gated to structure/resolver changes). Cluster/community summary panels remain DEFERRED (low value now). +- **Cross-mode camera re-centre (#34)**; node-edge clearance only if it still reads wrong (cola `routeEdge`, NOT perturbation; #31); untangle for small flat N is a visual-judgment call (#35). +- **Interactivity via a bidirectional SAB** (idea, deferred): pair the layout's OUTBOUND `[version]` with an INBOUND one the worker watches (`Atomics.wait`). The main thread writes a command — a dragged node's new position, a pin, a selection — and bumps the inbound version; the worker applies it and RE-ENERGISES the field (the same `#ensureSchedulerRunning` re-kick a streamed node triggers — a settled layout ignores its inputs until something re-energises it). Interactions then compose over the existing SAB with one atomic each, no new message channel. (The settled-layout re-kick is also why a warm absorb MUST call `#ensureSchedulerRunning`.) + +dagre is a candidate FUTURE _flow_ mode (directed semtype links), NOT the flat default (it'd impose a false hierarchy on cyclic community graphs). See `LAYOUT-MODES.md`. 3. **Bands spread along the arc** (mostly emergent): with radial perimeter-placed ports, the perpendicular lane offset already splays bundles along the rim tangent near the bubble. Explicit arc-curving of the lane endpoints is a polish to revisit only if it reads wrong. 4. **Arrowheads**: direction shown by separate forward/reverse lanes + width; triangle glyphs at the target port need visual tuning — deferred. 5. **LOD cross-fade**: transitions snap (bundle ↔ children/entities) BY DESIGN. An opacity crossfade (spec §4.4/§6.7) was ATTEMPTED and reverted -- it was visibly laggy (the reconciliation layer that keeps both LOD levels alive + animates opacity stutters during settle). Snapping is the accepted behaviour; do NOT re-attempt the crossfade without a fundamentally cheaper approach. 6. **Settle→commit streaming**: WebCola streams its own settling live (good), but the one-shot untangle on settle can still pop. Consider lerping the untangle delta. 7. **CutIndex is still O(all entities) per commit**: fine at 50k, needs spec §12 incremental cut updates for true 3M scale. Commits are now rare (not per-tick), so it's not on the hot path. 8. **Frontier**: fetched non-root link endpoints render greyed-out, with click-to-expand and a bulk "expand complete frontier" (`entity-graph-visualizer.tsx`; the worker tracks root-ness in `EntityStore` `#roots`). The dead `FrontierStore` scaffold is removed. A wholly-frontier hierarchical bubble (every member still frontier) renders in the frontier grey and surfaces an EXPLICIT load affordance -- NOT an implicit zoom/click side effect (loading the graph is too consequential to fire on navigation). The worker computes `RenderCluster.frontierCount` (membership walk in `#frontierMembers`) and, when it equals `count`, attaches the members' `frontierEntityIds`. `clusters.ts` greys the bubble; hovering it makes `scene.ts` emit `onClusterHover` (bubble-anchored, re-projected each frame like the highway card) carrying the count + ids + on-screen radius; the bridge shows `FrontierClusterCard` at the bubble's edge with a "Load entities" button that calls `expandFrontier`. The card is interactive, so it stays open while the cursor is over the bubble OR the card (a short grace timer bridges the gap). Bubble CLICK stays pure zoom. 9. **Filter change purges the old tree** (was a bug; FIXED): changing the page-level filter REPLACES the entity set, but the worker's ingest is additive (no retract). The fix is a `sourceKey` prop: a stable identity of the data SOURCE (the query/filter behind `entities`), NOT the result. `entities` streaming in for the same source keeps it constant (the bridge's `sentCountRef` tail-appends); a changed key means the source was REPLACED, so `useGraphWorker` (its `resetKey` dep) tears down and recreates the worker for a clean slate, and the `ready` false→true cycle resets `sentCountRef` + the frontier refs before re-ingesting from zero with the new `rootIdSet`. The KEY (not content diffing) is the signal because it's exact and O(1); recreation (not an in-worker purge) reuses the proven INIT teardown and leaves worker internals untouched. The main view derives the key from `serializeFilterStateToQuery` + type scoping (excludes cursor/sort, so pagination doesn't reset); the entity-editor ego graph uses the seed `entityId`. Omitting `sourceKey` (no source identity) means no recreate -- `entities` is then assumed append-only. 10. **Incremental loading caps at ~10k and isn't smooth**: the page-level fetch stops after ~10k entities and lands in chunks. Needs continued pagination past the cap + smoother streaming (the cap lives in the entities query / `use-entities-visualizer-data`, outside our component). + +_Resolved_: Customer & Supplier both inherit from `Company` yet rendered as separate top-level `[other]` bubbles instead of grouping under a `Company` family. Root cause was NOT "Company wasn't sent" (it was registered) — it was an order-dependent bug in `TypeRegistry.#computeClosures` (see Things to not repeat). Found via the `[types]` console dump (`TypeRegistry.debugDump()` in `registerTypes`): every parentless type resolved `roots=[self]` but every type WITH a parent resolved `roots=[]`, which is impossible under correct propagation. Fixed + covered by `type-registry.test.ts`. (Two temporary debug dumps remain — `[tree]` in `commitStructure` and `[types]` in `registerTypes`; remove/gate once confirmed in-app.) + +## Things to not repeat + +- NEVER perturb WebCola's positions to "fix up" a layout — mutating `colaNodes.x/y` (or the SAB it streams) between/after ticks corrupts the descent + VPSC (non-overlap) state cola keeps across ticks, and nodes pile up (the flat-tier node-edge "avoidance" pass did exactly this → everything colliding, the layout visibly WORSE). cola must OWN every position. Express intent through its API: `jaccardLinkLengths`/`symmetricDiffLinkLengths` (neighbourhood-aware lengths — a single uniform link `length` makes a hairball; this is what makes a cola layout actually read), `avoidOverlaps` (non-overlap), `constraints(...)` (structure), `routeEdge`/`prepareEdgeRouting` (node-avoiding edge routes — node-edge clearance is rendering/routing, NOT moving nodes). rendering != layout, and the layout engine's internals are off-limits. +- To STREAM a cola layout tick-by-tick (event queue + SAB), drive `Descent` DIRECTLY, not `Layout` (its `start()` runs the phases synchronously — it can't stream). Recipe + the gotchas that cost hours (all source-verified in `flat-layout.ts`): build `D` via `Calculator(...).DistanceMatrix()` — `Infinity` for disconnected pairs is INERT (cola's gradient zeroes non-finite distances; do NOT sanitise). `new Descent([xs,ys], D)`. **Phase A (unconstrained)**: leave `descent.project` at its default `null` (cola's `.d.ts` MISTYPES it non-nullable — don't assign null, just don't set it), step `rungeKutta()` — BUT cap the iterations: `rungeKutta()` returns DISPLACEMENT, which →0 at settle, so the `|prev/d − 1|` ratio test NEVER trips; the cap is what terminates the phase (exactly as cola's own `run()` bounds it). Then **pack** disconnected components (`separateGraphs`+`applyPacking`, which work on node `.x/.y` — sync to/from `descent.x`). **Phase B (overlap)**: `descent.project = new Projection(nodes, [], rootGroup, [], true).projectFunctions()` — the constraints arg MUST be `[]` not `null` (`Projection` only inits its xConstraints/yConstraints when it's truthy, and `project()` does `.concat` on them → `undefined.concat` crash) — and attach the `G` weight matrix (default 2 = push-apart-only, 1 for edges); step `rungeKutta()` for a FIXED iteration budget (VPSC is stress-neutral, so a convergence test quits before overlap resolves — this is "avoidOverlaps not fully working"). `jaccardLinkLengths(links, accessor, w)` writes lengths via the accessor (keep them in a side `Map`, don't mutate the link object → `no-param-reassign`); feed `idealLength · length` to the `Calculator`. All cola symbols are re-exported from `"webcola"`. +- To STREAM FA2 (the `community-force` tier), drive its `iterate(settings, nodeMatrix, edgeMatrix)` primitive DIRECTLY — one step per scheduler tick over flat `Float32Array` matrices you BUILD and own — NOT the blocking batch `forceAtlas2(graph,n)`/`assign` (rebuilds matrices + round-trips a graphology graph). Matrix layout (library-defined): node = PPN=10 floats `[x, y, dx, dy, old_dx, old_dy, mass, convergence, size, fixed]`; edge = PPE=3 floats `[sourceOffset = idx·PPN, targetOffset, weight]`; `mass = 1 + Σ incident weight` (hubs repel harder); `size = radius + pad` (for `adjustSizes`). `iterate` is an UNTYPED subpath (`graphology-layout-forceatlas2/iterate`) → shim it with a **SCRIPT** `.d.ts` (`declare module` + `export =`; a top-level `import` turns the file into a module and the shim silently stops applying → TS7016 — reference the cross-package type via inline `import()` instead). Settle DETECTION is yours: `iterate` returns `{}` and accumulates NET FORCE into dx/dy, so snapshot positions and stop once the max per-node MOVE floors, past a min-iters base, capped. Use `inferSettings(order)` (sets scalingRatio / gravity+strongGravity / slowDown / `barnesHutOptimize: order>2000`) and force `adjustSizes: true` for anti-overlap — but adjustSizes only takes effect on the EXACT repulsion path, so keep N below the Barnes-Hut cutoff (it ignores node sizes). FA2 `gravity` holds disconnected components together → NO packing step → NO bounding box (the cola tier's `applyPacking(desired_ratio=1)` squares disconnected components into a box; FA2 doesn't). Louvain needs a real `graphology` graph + a SEEDED rng with `randomWalk: false` for determinism. +- A custom Deck layer needing RANDOM per-pixel data access (the BubbleSets metaball summing each community's node positions per pixel) uses a TEXTURE, not vertex attributes. luma 9.3 pattern (runtime-verified in `bubble-set-sdf-layer.ts`): declare `uniform sampler2D positionsTex;` in the FS; create with `device.createTexture({ format: 'rg32float', width, height, data: Float32Array, sampler: { minFilter:'nearest', magFilter:'nearest' } })` — NO `mipmaps` field (not in `TextureProps`); bind by NAME each `draw()` via `model.setBindings({ positionsTex: texture })`; read with `texelFetch(positionsTex, ivec2(idx % texWidth, idx / texWidth), 0)`. (Re)create the texture in `updateState` when its data prop changes; destroy the old one and destroy in `finalizeState`. GOTCHA that cost a parse error: a BACKTICK inside a GLSL comment in a template-literal shader string CLOSES the template early (`Parsing error: ',' expected`) — never put backticks in shader comments. Model new GPU layers on `BezierSDFLayer`; the bubble layer is the first texture-using one here. +- cola CANNOT absorb a streamed node; FA2 CAN — that's WHY FA2 owns the tier where incremental matters. cola's `Descent` is built around a fixed N×N distance matrix (one more node = a new matrix = a rebuild); FA2's matrices just grow by a row and it keeps iterating from current positions. community-force NOW warm-absorbs in place (#36 built — `CommunityLayout.absorb`: append rows at the end so existing SAB records keep their slot, rebuild edges from the preserved positions, keep iterating; the over-allocated SAB takes the new records and a count+version bump publishes them with no realloc/re-send). cola (flat-force) still rebuilds warm-seeded each batch — it has no `absorb`. Don't try to bolt incremental onto cola, and when absorbing append at the END (never globally re-sort — that would shift every existing record's slot and defeat the in-place growth). +- `Atomics.store` does NOT notify waiters. Must call `Atomics.notify` after store. +- `Atomics.waitAsync` returns `{async: false, value: "not-equal"}` when version changes between load and wait. Must handle the synchronous branch and re-arm. +- `typeof SharedArrayBuffer !== "undefined"` before `instanceof`. +- `typeof Atomics.waitAsync === "function"` before calling it. +- `queueMicrotask` in a worker starves incoming messages. Use `MessageChannel`. +- `rebuildClusters` must invalidate ALL force layouts + clear port cache + reset edge aggregator. +- CutIndex must be viewport-independent. Frustum culling is Deck.gl's job. `computeVisibleCut` was VIOLATING this with an `if (!viewBbox.intersectsCircle(node.circle)) continue` that dropped off-screen clusters from the cut — so panning a cluster off-screen removed it from `#rendered` (the obstacle list), and edges suddenly re-routed "as if it never existed", plus it re-committed on every pan (different layout each pan). INCLUSION must not depend on pan; only whether a cluster OPENS is viewport-gated (centerInView + hysteresis, so it doesn't snap shut when panned out). +- A cluster layout that fully SETTLES during its creation warm-up (`layout.tick(20)` in `#ensureChildrenLayout`) is then SKIPPED by the scheduler loop's `if (status === "settled") continue` — so any "on settle" work (the top-level optimiser, the untangle) NEVER runs for it. Small layouts (the root's handful of top-level clusters) settle in the warm-up nearly always, so the optimiser silently never ran and the top level stayed raw WebCola (looked identical to "before"). Run the settle-polish from `#ensureChildrenLayout` too (when `layout.isSettled` after warm-up), via a shared `#polishSettledLayout`, not only on the tick-loop settle transition. GENERAL lesson: if work is gated on a state TRANSITION inside a loop that `continue`s past that state, it misses anything that reached the state before the loop first saw it — and a `console.log` that never prints is the fastest way to catch it. +- Cubic Bezier: p2 = target + targetNormal × tension (NO angle flip). Oracle confirmed. +- Feeder endpoint angle faces INWARD. `atan2(child.y - port.y, child.x - port.x)`. +- GLSL 300 ES reserves `common` as a keyword. +- Edges poked THROUGH bubble walls: the round SDF cap overshoots the perimeter by a half-width, and because the edge layer is TRANSLUCENT you see the overshoot on BOTH sides — so draw-order (bubble on top) or an endpoint inset can't hide it. The only true fix is a geometric CLIP in the bezier fragment shader: erase the edge wherever it's on the wrong side of an endpoint bubble (`alpha *= clipFactor`), so there's literally no geometry to show through. It's DIRECTIONAL. A HIGHWAY is a single span between the two OUTERMOST containers, so only its two ends clip — both to OUTSIDE the endpoint bubble (flush on the outer wall; signed radius > 0 erases inside). A FEEDER traverses the nesting, so clip EVERY hop at BOTH its container walls: leave the source's OUTER wall (erase inside the source) and reach the target container's INNER wall (erase outside it; signed radius < 0). This must be RECURSIVE — clipping only the child + outermost ends left the INTERMEDIATE container walls unclipped, where two consecutive hops' round caps overlap into a blob poking through the (translucent) wall (visible at depth ≥ 2 inside a subcluster). Plumb the clip circles per segment (`ClipCircle` → `BezierSegmentSink` → `clips` buffer → `instanceClipA/B`). Pixel radius is found by projecting centre + (worldRadius, 0) so it tracks the bubble's own projection. +- Don't send high-frequency per-child messages. Stream positions via SAB (entities) or one bounded `PositionsFrame` (clusters); never one message per node per tick. +- Entity ForceNode.id = String(entityIdx), NOT the entity UUID. +- `mergeLanes` must use ONE side of children (source or target), not both. Both sides contain the same edges; merging both doubles every lane count. +- Entity-ownership collection must recurse into children for rollup/family nodes that have empty `keys` — and there are TWO such functions, easy to fix one and miss the other. `collectEntityOwnership` (edge-aggregation, the RENDER side) AND `#collectEntityIdxsForCluster` (index.ts, the OPTIMISER's `#buildClusterEdges` side). When only the render one recursed, a family (Company = Customer+Supplier, empty keys of its own) collected no entities for the layout, so a link INTO the family (Delivery → Customer) was dropped from the cluster-edge set: the optimiser got no `Company↔Delivery` edge, the family floated far with nothing to pull it in, yet the renderer still drew the (canvas-spanning) edge. Symptom: `[top-level] e=6` while the screen shows more edges than that, one of them enormous. Recurse only when the node has no OWN entities (a subdivided type-set's keys already cover its whole partition — recursing would double-count). +- Entity-mode transition (cluster → entities) doesn't change entity ownership. Must also check `isEntityMode` flip in `#findChangedEntities`. +- `forceEdgeAvoid` is O(nodes × edges) per tick — only run it for cluster layouts (≤96 nodes); entity layouts (≤500) would stall the scheduler. Lives in `cluster-layout.ts`. +- Worker recreation must clear the SAB registry (`entitySabsRef`) plus the structure/positions refs. Stale SAB references from a terminated worker cause frozen dots. +- PortCache is keyed on positions + neighbor set, NOT zoom (zoom-independent slotting → pan/zoom never reshuffles ports). Compute ports at the HIGHWAY level — collapse each neighbor to its outermost rendered container — so opening/closing a container doesn't change a cluster's neighbor set and reshuffle its ports. (The older "key on zoom too" advice was wrong: it caused the reshuffle.) +- Viewport handling must NOT commit the LOD cut unless `wouldChange()` says so. `commitStructure()` is the sole authority that commits the cut and creates/destroys layouts. +- The dead tessellated pipeline (`buildEdgePaths`) was computed every frame and discarded. Remove dead code, don't leave it running. +- Subagent preamble must allow restructuring/implementation, not just bug fixes. The flat-arrays agent refused because the preamble said "fix bugs only." +- Subagent worktree diffs need `git add -A` before `git diff --cached` to capture new files. +- Split the render pipeline by update rate. A per-tick full-frame rebuild (cut + CutIndex over all entities + aggregation + Bezier) funneled through React state is what caused the OOM and the 30fps band-aid. Topology work belongs in `commitStructure` (rare); positions stream via SAB + a bounded `PositionsFrame`. +- Cluster positions are bounded by the render budget (hundreds), so they travel by `postMessage`. Only entities are millions-scale and need a `SharedArrayBuffer`. +- Deck.gl binary attributes go in `data.attributes` keyed by accessor name (`getPosition`, `getSourcePosition`, …), NOT as top-level `{value, size}` props. A fresh typed-array identity each frame re-uploads; gather visible world coords into bounded buffers and you avoid `modelMatrix`/`updateTriggers` entirely. +- The collinear bow side must come from chord direction (continuous, never flips), not an FNV hash of endpoint ids — the hash flips when an LOD change swaps highway endpoints (container opens), which reads as a jump. +- Confined cluster sub-layouts need an inward `forceX/Y` spring; `forceCenter` alone (no spring) + charge + confinement piles children on the boundary in a ring. Top-level (unconfined) must NOT use the spring (it would compress the spread) — use `forceCenter`. +- Don't freeze a force layout at alpha 0.01; the last stretch (0.01 → d3's `0.001` floor) is where `forceCollide` removes the final overlaps. `SETTLE_ALPHA = 0.001`. (Backgrounded tabs pause rAF, so a hidden tab "finishes" settling silently and looks better on return — that's a symptom, not magic.) +- Pack parallel lanes snug BY WIDTH (cumulative half-widths + a small px gap), not a fixed center-to-center spacing — fixed spacing overlaps wide lanes and gaps thin ones. +- Offsetting a ROUTED (multi-segment) bezier chain lane-by-lane along EACH segment's own chord normal makes the lane ZIG-ZAG at every waypoint: the shared endpoint gets offset to two different places. Offset the whole chain with the BISECTOR normal at shared vertices (`offsetPolyBezier`) so joins stay continuous. The centre lane (offset 0) hides the bug, so direct edges look fine while routed ones sawtooth. +- `cubicBetweenWaypoints` places the END handle at `p2 = p3 + tension·dir(to.angle)`, so `to.angle` must point BACKWARD (from p3 toward where the curve came). For a port that's the outward normal (≈ toward the source); for a routed interior waypoint it's the through-tangent REVERSED. Setting it to "toward next" pushes p2 PAST p3 (further from p0 than p3) → the cubic overshoots and loops back on itself, a fishhook at every waypoint. START handle forward, END handle backward. +- Port slotting is PERIMETER PLACEMENT (`placePortsOnPerimeter`), the right mental model: a port is a node pinned to the rim, free to SLIDE. Its ideal angle is straight toward its target (nearest rim point → short leader, and no cutting back through the bubble); ports reserve an arc (∝ lane count, so bundles get room) and slide the MINIMUM total amount to stay non-overlapping AND in cyclic order. That is isotonic regression (PAVA) once you cut the rim at the largest free gap and fold out the cumulative arc offsets — the 1-D form of VPSC (the same separation solve WebCola runs for node non-overlap). The old monotonic "push each port `minSep` past the previous" drifted clustered ports right around to the WRONG SIDE (→ leaders cut back through the bubble); never do that. +- WebCola's stress layout spreads WIDER than the cluster tree's circle-packing, so a confined sub-cluster overflows its parent bubble ("nodes sticking out"). WebCola has no circular bound and overwrites node positions each tick (post-tick clamping doesn't feed back into its descent), so fit the OUTPUT instead: clamp into the circle + a few overlap-relax passes (`#fitWithin`). A contained non-overlapping fit exists because the tree packed them. +- A port POSITION is positional data, not structural — it belongs in the PositionsFrame, NEVER the StructureFrame. Re-emitting the whole structure to update a moving fan-out exit re-derives every layer + re-uploads the GPU each tick → OOM (the original failure mode, briefly re-introduced by exactly this mistake — chasing a "stale port" by re-emitting structure). The fan-out exits live in `PositionsFrame.entityFanOut` (`#buildEntityFanOut`, per tick); the StructureFrame carries only topology (`internalEdges` — which dot links which) and re-emits ONLY on a real cut change. Same rule for sub-cluster anchors: move them in place (`updateAnchorPositions`), don't re-run or re-emit. The StructureFrame/PositionsFrame split exists precisely so positional churn never touches topology — respect it. +- A force only moves nodes WHILE its sim is running; once it settles (alpha < floor) it ignores its inputs. So the entity port targets, which drift as the macro re-settles, leave the dots frozen at the OLD target once the sim settles — the "port stuck at the old position" bug. A movement THRESHOLD to decide when to `resume()` is itself a trap: sub-threshold drift (the macro's slow settle tail) accumulates into a large offset that never re-energises (a tracking lag — distinct from the depth-≥2 fan-out-exit GEOMETRY offset, which was aiming the exit at the wrong waypoint). Tie liveness to the CAUSE instead: while ANY cluster layout is running (`#anyClusterLayoutRunning`), keep every entity layout warm (`resume()` each tick) so it tracks the drift, and let it settle only once the macro has (plus a connectivity-flip check for reopen / highway re-route while the macro is settled). The scheduler keeps ticking while ANY layout is running (`#anyLayoutRunning`), not "did a layout tick this turn" (a just-resumed sim hasn't ticked yet). +- A nested leaf's WORLD position = its parent's world + its own LOCAL layout offset, one level per layout. When an ancestor moves, an intermediate layout that's already SETTLED won't tick, so its subtree keeps the parent's OLD world offset — the depth-≥2 "port stuck at the offset" bug (invisible at depth 1, where the moved layout is the one that ticked). Don't gate the recompose on "a layout ticked" (it misses commit ticks where the macro is already settled, and sub-threshold drift): recompose AUTHORITATIVELY before every positional read. `#syncWorldPositions` (→ `syncWorldPositions`) walks the opened subtree top-down (parent before child, bounded by stopping at closed/entity nodes), setting world = parent + fixed local THROUGH settled intermediates; it runs at the top of `#emitPositions` so commit-time emits are correct too. The local layout never re-runs — only the world offset propagates. (`world-positions.test.ts` proves the depth-2 carry-through.) +- The entity fan-out exit must aim at the FEEDER's first waypoint, NOT the outermost highway port. The feeder leaves a bucket toward its nearest enclosing OPEN container's boundary (`containerBoundaryWaypoint`, in the direction of the outermost port), then hops outward; aiming the fan-out straight at the outermost port coincides ONLY at depth 1 (bucket directly in the outermost container). At depth ≥ 2 an intermediate container makes the two diverge by a position-dependent few degrees — it reads like a flickering/timing offset ("4 vs 5 o'clock, sometimes fits") but it's pure geometry, constant per frame. Share the SAME `containerBoundaryWaypoint` fn between the fan-out (`#buildEntityFanOut`) and the feeder (`emitRecursiveBezierFeeders`) so they can't drift apart. (`entity-fanout-exit.test.ts`.) +- The recursive feeder has the SAME trap internally between its hops. Each hop ENDS at a container boundary aimed at the outermost port; a PASS-THROUGH container's next hop must START at that same point (its boundary toward the outermost), NOT be re-projected onto its rim toward its OWN next hop. Re-projecting makes consecutive hops disagree by a few position-dependent degrees at every nested intermediate — continuous with ONE intermediate (its parent IS the outermost, so it aims there anyway), drifts with ≥2. Only the original participant (a `children` id) leaves toward its own first boundary; pass-throughs aim at the outermost. Recursive — works at any depth, because every container's crossing is the one point `containerBoundaryWaypoint(container, outermost)` used by both the arriving and the leaving hop. (`feeder-continuity.test.ts`.) KNOWN RESIDUAL: a sub-cluster that is BOTH a participant (has its own external edges) AND a pass-through ancestor of a DEEPER participant, while itself sitting ≥2 levels deep, shares one hop that uses the participant routing — so the deeper path through it can still drift a hair. Fix if it shows up: track the crossing per HOP, not per source id (per-hop bookkeeping), so a shared source can leave at the outermost-ward crossing for the pass-through path and toward its own boundary for its own path. +- An iterative relaxation that SUMS a per-node nudge over many contributors is UNSTABLE: `pos += strength·(Σtarget − degree·pos)` DIVERGES once `strength·degree > 2` (degree ≥ 11 at strength 0.2). This bit a since-removed "mitosis seed" — a high-degree top-level cluster blew it to huge off-screen positions → BLANK SCREEN once edges were added. If you ever write an "accumulate then apply" relaxation: apply the AVERAGE (÷ contributor count) so each node is a stable contraction, AND pin the size each iteration (a uniform rescale about the centroid preserves every direction, so angular structure is untouched while the extent can't drift — averaging alone still crept ~250× on a dense graph), AND guard at the call site so it can never emit non-finite. Test the DENSE / high-degree case, not just a star (degree-1 spokes hide it). (The neighbour-spread is now an objective TERM in the top-level optimiser — evaluated, not accumulated — so it can't diverge.) +- Ports-as-constraints uses WebCola FIXED nodes (`fixed: 1`) as rim anchors the children link to. Two gotchas: (1) `start()`'s disconnected-component packing can MOVE fixed nodes, so disable `handleDisconnected` on the anchored re-run; (2) append anchors AFTER the real children and key the SAB/output off the child count, so the extra layout-only nodes never leak into rendered positions. Apply once while the layout is still `running` — don't re-settle a reused, already-settled sub-cluster. +- OrthographicView view state must use `zoomX/zoomY` ONLY (never scalar `zoom`); mixing them makes Deck compute a transition's start from an inconsistent zoom and the target jerks. Interpolate `["target","zoomX","zoomY"]`. It renders y-down (flipY), so negate a world tangent angle before using it as a TextLayer `getAngle`. +- A `STRUCTURE_FRAME` and its paired `POSITIONS_FRAME` must reach the view atomically: defer the `structureVersion` bump until the positions land, else the layer memo runs once with new clusters + index-misaligned old positions (single-frame commit flicker). +- The cluster layout (WebCola) resolves links to indices locally and does NOT mutate the input edges — unlike d3 `forceLink`, which rewrote `edge.source`/`edge.target` in place. The untangle index pairs are still captured from edge ids before layout (correct either way). +- TS narrows a status getter after a `=== "settled"` guard + `continue`, so a later `layout.status === "settled"` reads as "always false" even though `tick()` mutated it at runtime. Expose a boolean getter (`isSettled`) instead of comparing the literal again. +- D1/untangle uses a SEEDED PRNG (seed = cluster id hash), so re-annealing the same cluster reproduces the same layout — no reshuffle on re-open. Never `Math.random()` in layout code. +- The top-level edge-length ideal must be ADDITIVE (`r_a + r_b + gap`), NOT multiplicative (`(r_a+r_b)·k`). A multiplicative ideal scales with bubble size, so any edge touching a huge bubble (Material Movement, r≈371) "wants" to be hundreds of px long — a connected small node is flung far AND the stress term reads it as near-ideal, so the optimiser never pulls it in. The tell was `[top-level] … stress 0.4→0.5 maxEdge 1086px maxRatio 1.5`: the optimiser ran and dropped energy (via crossings/detours), but the worst edge was 1086px at only 1.5× its (radius-dominated) ideal, so stress was a non-factor. Additive gap (`meanRadius · IDEAL_GAP_FRAC`, kept ≥ the overlap pad so they don't fight) makes the desired RIM separation modest + uniform regardless of size → connected nodes sit close. In `optimizeTopLevel`'s `buildProblem`. (WebCola's `ROOT_SEP_MUL` seed is still multiplicative, but the optimiser overwrites its positions, so the optimiser's ideal is what the render shows — diagnosed by re-adding the `[top-level]` log after the diagnostics cleanup had removed it; lesson: a layout-quality "force feels wrong" bug needs the per-edge length/stress numbers, not eyeballing.) +- TypeRegistry root resolution must be ORDER-INDEPENDENT. `#computeClosures` computed ancestor CLOSURES via a fixed-point (`while (changed)`) but the ROOTS as a single forward pass over idx order — and parents are interned LAZILY from `allOfRefs` (a parent URL is first seen as a child's ref, so the parent usually gets a HIGHER idx than the child). The single pass read the parent's not-yet-computed roots (`[]`) and zeroed the child → `#bucketByPrimaryRoot` dropped it into the `undefined` ("unknown"/"Other") bucket. The tell in the `[types]` dump: parentless types resolve `roots=[self]` but every type WITH a parent resolves `roots=[]` — impossible if propagation were correct. It masquerades as "the parent type was never sent" (the first, plausible, WRONG hypothesis — the parent IS registered). Fold roots into the SAME fixed-point as closures so a child re-reads its parent once the parent's roots exist. (`type-registry.test.ts` reproduces child-interned-before-parent; all 3 cases failed pre-fix.) GENERAL: any DAG propagation (roots, closures, depths) over lazily-interned ids needs a fixed-point or a topological order — never a single pass in insertion order. +- The worker must receive the FULL inheritance closure, not just the entity types that are directly applied. `TypeRegistry.registerAll` INTERNS any `allOfRef` URL (hands it a `TypeIdx`) but only a type with an actual schema gets a `TypeInfo` + a `roots` entry — so a supertype referenced via `allOf` yet never sent (e.g. a shared `Company` parent that NO entity uses directly) resolves to `rootIdxs: []`, and `#bucketByPrimaryRoot` drops every child of it into the `undefined` bucket → a nameless catch-all rollup (rendered as just `(count)`, or `Other` once labelled). Fix is upstream in `extractTypeSchemas` (entity-graph-visualizer.tsx): walk each `entry.allOf` chain (depth-ordered — self at 0, then ancestors) and register EVERY element, not just the leaf. Ancestor entries are `EntityTypeDisplayMetadata` (carry `$id`/`depth`/`icon` but NO title), so titles come from the `definitions` map — which had to be plumbed `entities-visualizer.tsx` → `EntityGraphVisualizerV2`. Symptom that points straight here: unrelated types lumped under one "Other" bubble. (Set a child's `allOfRefs` to ALL deeper chain entries — transitive over-approximation is harmless for root resolution = union of parents' roots.) +- Node radii are assigned by ONE bottom-up pass (`#assignRadii` → `#sizeNodeRadius`, run at the top of `#layoutTopLevel`/`#stableLayout`, before positioning), NOT ad-hoc per layout. Leaves are sized by count (`max(LEAF_MIN, √count·k)`); a FAMILY rollup grows to `max(countRadius, enclosingRadius(children)+pad)` so two small siblings can't overlap inside a container sized only by its small aggregate count (the `Company (6)` family was a count-6 leaf radius of 15 holding two `r=8` children → overlap). Root children then get a larger visibility floor (`TOP_LEVEL_MIN_RADIUS`). Crucially `#sizeNodeRadius` treats a type-set as a LEAF even when it has subdivision children — those pack top-down within its count-based radius (`#layoutChildrenInParent`), so drilling into a big type-set doesn't reflow the top level; only `family` containers grow. `#writeChildCircles` writes back only x/y (never radius), so this pass is the single source of size truth and a never-laid-out child can no longer render at `r=0`. `enclosingRadius` uses a ring (always a valid non-overlap arrangement → the confined force layout can pack within it). (`cluster-radii.test.ts`; found by `ClusterTree.debugDump()` showing a `(1)` sibling at `r=0`, then overlap once it was sized.) +- Rollup/family nodes (`#makeRollupNode`: the per-root-type families in `#buildDisplayHierarchy`, the overflow "More") get NO label unless you set one — `#computeLabels` runs BEFORE `#buildDisplayHierarchy`, so it never sees them, and an unlabelled bubble renders as a bare `(count)`. Label families from their shared root type's title (fallback "Other" when the title is missing OR empty — use a length check, not `??`, since the degraded type-supply path yields `""`), overflow as "More". +- Untangle runs ONCE per layout, on the settle transition (tracked in `#untangled`), not every tick — it's a polish pass, not a force. +- An anchor-to-seed term is a SHORTCUT, not a stabiliser: it pins the optimiser to whatever local minimum the seed fell into and forbids the big beneficial rearrangement. Optimise the true objective over the full space (multi-restart, energy-scale temperature); use a prior layout only as a warm START the optimiser may abandon. (Determinism comes from a seeded PRNG, not from anchoring.) +- WebCola `Layout.tick()`/`kick()` are PROTECTED. `kick()` busy-loops to convergence (don't call it — it blocks the worker). To stream: subclass to expose `tick()` as a step, call `start(0,0,0,0,false,false)` (build the descent + distance matrix, run 0 iterations, don't auto-run), then `alpha(0.1)` to set it running, then your step per time budget; `tick()` returns true at convergence. +- WebCola non-overlap (`avoidOverlaps(true)`) uses RECTANGLES (node `width`/`height`), so circles get bounding-box separation — pad the box (`2·(radius+pad)`) for a visible gap. Don't use a single `jaccardLinkLengths` ideal when node sizes vary hugely (top-level clusters): it packs the big ones to touching. Set per-link `length` from the endpoints' radii instead. +- `updateNodePositions` writes descent→node every tick; mutating `node.x/.y` afterward does NOT feed back into the descent. Re-centre the SAB OUTPUT, not the cola nodes (and don't try to confine by clamping node positions post-tick — the descent overwrites it next step). +- A bubble that ENCLOSES an edge's endpoint must be exempt from obstacle routing (the edge has to enter it). Test geometrically (`containsPoint`, endpoint within radius·tolerance) — this covers the endpoint's own highway container AND every enclosing ancestor, opened clusters included, without walking the tree. +- A custom 2D layer that fills a QUAD with mostly-transparent content (the BubbleSets hull, an SDF glow) must NOT write depth. With depth-write on, the whole bounding quad stamps the depth buffer — even the transparent margin — and any COPLANAR layer drawn AFTER it (the bezier edges) fails the depth test across that rectangle and vanishes, while deck's BUILT-IN layers survive via a per-layer polygonOffset the custom layer doesn't share. The fix is per-layer, not global: a backdrop sets `parameters: { depthWriteEnabled: false, depthCompare: "always" }` (it's behind everything, never occludes, nothing is behind it). Don't globally disable depth to paper over it — that's a 3D tool, but the misuse is one layer's, not the scene's. Symptom: nodes still draw over the hull but edges are erased inside its box. +- A version-guarded async result must claim its version BEFORE capturing it: `#v += 1; const mine = #v; …later: if (mine !== #v) drop`. Capturing BEFORE the bump (`const mine = #v; #v += 1`) leaves `mine` one behind forever, so EVERY result drops — the ego highlight silently never landed. (`#queryEgo`.) +- A LANE is single type + direction by definition. Group/merge highway lanes by (outer endpoints, typeSetIdx, direction); keying on endpoints alone folds distinct single-type lanes into a bogus "N link types" rollup, and a clicked merge then opens the wrong union. (`#buildHighwayLanes`.) +- Label a reverse lane with the link type's `inverse.title` ("Member Of"), not its forward `title` ("Has Member"). The hover card resolves it from the closed type schema per direction; the worker also carries an `inverseTitle` per type so the worker-built RIBBON labels are per-direction too. +- The worker ships type IDENTITY (a `VersionedUrl`), NEVER rich type data (icon, titles). The main thread resolves icon/title/inverse from the closed type schema it ALREADY holds (`getDisplayFieldsForClosedEntityType` walks the hierarchy). "We have the schema — don't round-trip it through the worker." (INTERACTION.md: worker computes, main thread presents.) +- Only HUBS get an always-on label; everything else is hover-only. A name on every dot reads cheap. HUB SELECTION lives in the Scene, keyed on the worker's by-degree RADIUS in the flat SAB -- NOT a main-thread degree tally over the prop `entities`. That earlier tally (`hubIds`, 95th-pct) couldn't see links the worker holds but the prop doesn't -- above all frontier-expansion links (they land in `expandedByIdRef`/the worker, never the prop) -- so an expanded hub the worker sized large still read as degree ~1 on the main thread and got no label. Radius is the single connectivity authority (expansion included), so the Scene ranks by it: top `HUB_LABEL_MAX_COUNT` above a degree-`HUB_LABEL_MIN_DEGREE` radius floor that also clear the size bar. Entity labels are HTML overlays in the hash design language (`entity-label-overlay.tsx`), not a deck `TextLayer` — GPU text doesn't match the cards/chips the rest of the app uses. They sit to the RIGHT of the dot, vertically centred (text-start anchor), so they hold steady beside the dot as the camera zooms. +- An always-on label/card that must track the settling layout has to be RE-EMITTED where positions change (each frame), not built once on zoom/structure — store a WORLD anchor and re-project it every frame (the `#emit*` pattern). A deck layer whose `getPosition` reads the SAB still won't move unless the layer is rebuilt with a fresh tick, so for HTML we project in `#emitEntityLabels` per frame instead. +- `degreeById` (and the worker's by-degree radius) counts every loaded link incident to a node, INCLUDING links whose other endpoint isn't loaded — so a hub's degree can exceed its visible neighbour count (the rest are frontier / not-yet-paginated). Hub-ness reflects TRUE connectivity, not on-screen edges. It tallies the prop links AND the frontier-expansion links (held in `expandedByIdRef`, keyed by `frontierVersion`) — the same union the worker ingested — so a node enlarged by expansion reports its true loaded degree, not just its prop-visible links. +- The community-force seed is a TICK-BUDGETED sparse-stress seeder (pivot-MDS init + localized SGD, ~O(N^1.5)), NOT a dense `Descent`. A dense NxN stress step is O(N^2) and blocks the worker tick; the seeder's `tick({ maxWork })` keeps each step bounded so streaming stays smooth. It writes separate `x[]`/`y[]` Float32Arrays the layout owns and updates in place (no per-step serialization), jitters DETERMINISTICALLY (`SEED_JITTER` is a fraction of the ideal edge length, `randomSeed` fixed) so coincident nodes separate enough for FA2 to take over, and hands off by a plain copy into the interleaved FA2 matrix (the jitter is the seeder's job, NOT a `cos/sin` handoff hack). With this seed in place the flat cap (`communityColorExitNodes`) can be raised past ~1200 (it is a USABILITY ceiling now -- 5k+ renders sub-second and looks good, but is too dense to read -- not a perf one). (`sparse-stress-seed.ts`, `community-layout.ts` `#buildSeed` / `#handOffSeedToFa2`.) +- Categorical TYPE colours must key off a STABLE per-type ordinal, NEVER an arrival-order intern index -- the index depends on the order types stream in, so the same type drew a different colour on every reload (the bug). The registry assigns each type a colour SLOT, sorted-by-base-URL within each registration batch and append-only (a later page / frontier expansion never re-slots what is already on screen); hue = slot \* golden angle. Do NOT hash the URL straight to a hue: that is uniform, so distinct roots land near each other (company vs person measured 1.7deg apart) -- the golden angle on a DENSE slot keeps them maximally separated. Nodes take their ROOT's slot (a family shares one hue, shaded by depth + a per-type URL-hash lightness jitter); EDGES take the link type's OWN slot, because link types ALWAYS share the `Link` root and keying edges off the root would collapse every edge to one colour. (`entity-style.ts`, `type-registry.ts` `#assignColorSlots`.) +- FA2 force baseline is inferSettings + forced `linLogMode: true, strongGravityMode: false` (`buildFa2Settings`). Plain FA2 (inferSettings' strongGravity + linear attraction) is a hub-and-spoke recipe -- dense core, radial degree-1 leaves; LinLog's logarithmic attraction pulls connected nodes tight and separates clusters, and strong gravity OFF stops it being crushed back to the origin (with strongGravity ON, LinLog made a warm-absorbed leaf land far from its only neighbour -- a unit test caught it). On top of the baseline, `config.fa2` (`Fa2Tuning`) overrides gravity / scalingRatio / linLogMode / strongGravityMode live, threaded `config -> createCommunityLayout -> buildFa2Settings`; the dev harness exposes them as knobs. Uniform scaling / seed-length changes are CAMERA-INVARIANT (the view auto-fits), so only the tight/loose RATIO (LinLog) and gravity actually change what you see. CAVEAT: the synthetic dev-harness fixture's topology is NOT representative of real entity graphs -- tune the magnitudes against real data, not the harness. + +## Deferred / known issues (stage-4+ backlog) + +Recorded so they aren't lost; tracked as todos too. Fix when tackling the hierarchical / perf work. + +- **Feeder hover/click shows the highway count, not the feeding entities.** A feeder (the cluster-internal "last mile" into a highway) resolves via Scene `#pickedHighwayLaneId` to the highway lane, so it reports the highway's aggregate link count — uninformative. It should report how many of THIS cluster's entities lead that way (count on hover; the entities on click). Make feeders pick-distinguishable from highway lanes, and resolve a feeder to the highway lane's links whose endpoint falls inside this cluster. +- **Hub always-on labels** (was a bug; FIXED): a clear hub stayed unlabelled because hub-ness was a MAIN-THREAD degree tally (`hubIds`, 95th-pct) over the prop `entities`, which can't see links the worker holds -- above all frontier-expansion links, which land in `expandedByIdRef`/the worker and never the prop. So an expanded hub the worker sized large (e.g. radius 15.2 ~= degree 2980) still read as degree 1 on the main thread -> no hub -> no label, at any zoom. Now the Scene picks hubs from the worker's by-degree RADIUS in the flat SAB (top `HUB_LABEL_MAX_COUNT` above a degree-`HUB_LABEL_MIN_DEGREE` floor that also clear the size bar), and `resolveEntityLabel` is a pure name resolver (prop OR expanded). The same prop-only blindness made the hover CARD's degree undercount ("1 link" on a 700-link expanded hub); FIXED by tallying `degreeById` over the prop links AND `expandedByIdRef` (the union the worker ingested), no worker round-trip needed since the expansion entities already live on the main thread. +- **Flat edges colour by the link's own type** (was a bug; FIXED): `#buildFlatRenderEdges` used `#colorForTypeGroup`, which keys off the type's ROOT (`colorForType`) -- right for NODES (a family shares a hue) but wrong for edges, since every link type shares the `Link` root, collapsing all edges to one colour. Now it uses `#edgeColorForTypeGroup` -> `edgeColorForType` (the link's primary DIRECT type's OWN slot), matching the aggregated/highway edge path that was already fixed. Distinct link types now read as distinct hues; a graph whose links are mostly one type still looks ~uniform (that's the data, not the colouring). diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/WORKER-REFACTOR.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/WORKER-REFACTOR.md new file mode 100644 index 00000000000..851e749b1fc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/WORKER-REFACTOR.md @@ -0,0 +1,106 @@ +# worker/ refactor: status + resume plan + +A pinned, resumable plan for the `worker/` cohesion refactor. Driven by a read-only survey +(31 agents) plus hands-on execution. Paused to finish the hover/interaction work first. + +## Conventions (apply throughout) + +- ASCII for prose/identifiers/comments: no em-dashes, no ASCII-art banners. Unicode IS + fine in genuine math (formulae, Greek vars, `O(N log N)`, exponents). +- Promote to a class when a thing owns state + behavior; meaningful names (never `sab`). +- Strongly typed (no `any`/loose casts). Use + create branded types and branded wrappers. +- Functions with more than 2-3 args take a config object: `(arg1, arg2, argConfig)`. +- Comment hygiene: keep WHY/invariants/gotchas, trim restating comments. +- Tests MUST be meaningful (round-trips, invariants, known-answer, edge cases). Never + tautological. Add co-located tests for newly-extracted pure logic. +- NEVER run git. The user commits. Move files with filesystem `mv`, not `git mv`. + +## Mechanics (proven in the moves) + +- Per cohesive step: `mv` files, fix imports (moved files: depth +1 for `../` externals, + `./collections|buffers|...` become `../...`, siblings stay `./`; importers: `./X` -> + `./folder/X`; external importers under `../worker/...`), then gate on + `tsc` + `eslint` (graph-visualizer-2) + worker `vitest`. No `index.ts` barrels (fractal). + +## DONE (committed, green) + +- **Phase A:** comment + ASCII hygiene across all 30 worker files. +- **Phase B moves:** flat dir -> `collections/ buffers/ stores/ layout/ hierarchy/ +geometry/ core/`; `index.ts` -> `core/graph-worker.ts`. `entry.ts`, `protocol.ts`, + `entity-style.ts` stay at `worker/` root. +- **Phase B leaf extractions** (worker/ root, each with a meaningful test): + `entity-id-codec.ts` (byte codec, shared with the main thread), `random.ts` + (`mulberry32` + `parkMillerRng`, both preserved exactly), `csr-graph.ts` + (`CsrGraph` + `buildInducedCsr` + `connectedComponents`). + +## REMAINING + +### Phase B splits (hit 500-700 lines/file) + +- `geometry/edge-aggregation.ts` (779) -> + - `pair-key.ts` (`makePairKey` + `PAIR_KEY_SEPARATOR`; test order-independence: a,b == b,a) + - `visual-edge-types.ts` (`EdgeDirection`, `ClusterEndpointRef`, `EntityEndpointRef`, + `Aggregated/IndividualVisualEdge`, `VisualEdge`, `EdgeFrame`; note these are + interleaved with internal mutable types that STAY) + - `cut-index.ts` (`CutIndex` + `collectEntityOwnership`; survey suggests `hierarchy/`) + - `explode-pair.ts` (`explodePair` + the mutable aggregation types; entangled with + `EdgeAggregator`, do carefully) +- `geometry/edge-geometry.ts` (1453) -> `bezier.ts` (cubic/offset/tangent primitives), + `waypoint-path.ts` (`Waypoint`/`containerBoundaryWaypoint`/`computeRawCurves`; the + `feeder-continuity` + `entity-fanout-exit` tests follow this symbol), `route-around.ts`, + `lane-aggregation.ts`, `segment-geometry.ts` (shared with untangle/top-level). +- `hierarchy/cluster-tree.ts` (1296) -> `cluster-node.ts` (value classes), + `cluster-labeling.ts` (TF-IDF), `cluster-merge.ts`, and `geometry/cluster-packing.ts` + (`enclosingRadius` etc; the `cluster-radii` test follows `enclosingRadius`). + +### Phase C: decompose `core/graph-worker.ts` (2369) into collaborator classes + +Smallest-first to validate the shared-private-state seam. Extract to `core/`: + +1. `embedding-controller.ts` (~90): `#pendingEmbeddingRequests`, `drainEmbeddingRequests`, + `applyEmbeddingResult`. +2. `layout-scheduler.ts` (~120): the MessageChannel tick loop (`#schedulerChannel`, + `#scheduleNextTick`, `#ensureSchedulerRunning`, `#tickAllLayouts`, running predicates). +3. `settle-polish.ts` (~160): `#polishSettledLayout`, `#optimizeTopLevelLayout`, + `#untangleClusterLayout`, `#writeChildCircles`. +4. `port-constraint-controller.ts` (~200): `#computePorts`, `#applyPortConstraints`, + `#updateAnchorTracking`, `#anchorEndpoints`, `#entityPortTargets` (+ the PortCache). +5. `geometry-emitter.ts` (~280): `#emitStructure`, `#emitPositions`, `#renderCluster`, + `#buildEntityLayers`, `#buildEntityFanOut`, the BezierSegmentSink. +6. `ingest-controller.ts` (~260): `registerTypes`, `insertNodeEntity`, `insertLinkEntity`, + `ingestBatch`, `#resolvePendingLinks`, `recomputeMode`. +7. `flat-tier-controller.ts` (~380): `#commitFlat`, `#rebuildFlatLayout`, + `#absorbFlatNodes`, `#seedFlatNodes`, `#writeFlatStyle`, flat bezier edges, + `#scheduleFlatLouvainLinger`, `FLAT_*`, `flatCapacityFor`, `#flatBuffer`. +8. `hierarchical-tier-controller.ts` (~340): `commitStructure` (hierarchical branch), + `#ensureChildrenLayout`, `#ensureEntityLayout`, `#buildClusterEdges`, + `#buildEntityEdges`, `#trySubdivide`, the `#rendered/#cutIndex/#edgeFrame` state. + +`GraphWorker` slims to composition + the public methods `entry.ts` calls. RISK: `#private` +state shared across responsibilities (e.g. `#flatBuffer`) becomes constructor-injected; +extract smallest-first to validate the boundary before the two tier controllers. + +### Phase D: branded types + interface configs (one family per step, after the moves) + +- Branded types: unify `PairKey`/ad-hoc cluster-pair keys into one `ClusterPairKey`; + `ForceNode` (kills the `String(idx)`/`Number(node.id)` round-trips); + thread `LinkIdx` (link-store getters, `StoredIndividualEdge`); `EntityIdx` end-to-end in + `CutIndex`; `TypeSetIdx` as map key; `ByteLength`/`RecordCount` in the buffers; + `LaneKey`/`HighwayGroupKey`; `ScreenPixels` vs world `Radius` in `lod.ts`; + `NodeIndex`/`EdgeIndex` in the layout engines; `BatchId`/`FrameId` in protocol; + a `GrowableBufferTransfer` alias for the repeated `SharedArrayBuffer | ArrayBuffer`. +- Interface configs (>3 args): `GrowableBuffer` ctor (HIGHEST: silent header/record swap + risk today), `edge-geometry` emitters (`emitRecursiveBezierFeeders` 9, `emitCurveLanes` + 7, `buildBezierSegments` 7), `cluster-tree` labeling (`bestCandidate` 6) + + `rebuild`/`updateIncrementally`, `edge-aggregation` (`#walkTree` 7, `explodePair` 5), + `bubble-ports` (`makePort` 8), `top-level-layout`/`untangle` geometry (`segmentsCross` 8), + the layout factories (shared creation config), `lod.computeVisibleCut`, + `link-store.insert`, `type-set-store` ctor. + +### Opaque renames (do in each file's split, mostly local) + +`hp`->`highwayPorts`, `cc`->`containerCrossing`, `wp`->`waypoint`, `kk`->`clusterCount`, +`df`/`idf`->spelled-out TF-IDF, `p`/`r`/`k`/`n` (bubble-ports)->role names, +`a`/`b` (makePairKey/comparators)->`firstClusterId`/`secondClusterId` etc, +single-letter HSL math -> shared `visual-style.hslToRgb` (edge colour now `entity-style.edgeColorForType`), +`#colaNodes`->`#webColaNodes`, `idToIndex`->`nodeIdToIndex`. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/brand.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/brand.ts new file mode 100644 index 00000000000..9f408a019ed --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/brand.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * The `Brand` module adds compile-time names to ordinary TypeScript values so + * structurally identical values cannot be mixed accidentally. A branded value + * has the same runtime representation as its unbranded value; the extra + * information lives in the type system unless you choose a validating + * constructor. + * + * Extracted from https://github.com/Effect-TS/effect-smol/blob/5a0c1a4faee5707b5cc35e646ff1ffdad70f1956/packages/effect/src/Brand.ts#L35 + * MIT licensed. + */ + +const TypeId = "~effect/Brand"; + +/** + * A generic interface that defines a branded type. + * + * **When to use** + * + * Use to define a branded type such as `number & Brand<"Positive">` when + * TypeScript should keep structurally identical values separate without + * changing their runtime value. + * + * @see {@link Branded} for applying a brand key to a base type + * @see {@link Constructor} for validating or constructing branded values + * + * @category models + * @since 2.0.0 + */ +export interface Brand { + readonly [TypeId]: { + readonly [K in Keys]: Keys; + }; +} + +/** + * Namespace containing type-level helpers for working with branded types and + * brand constructors. + * + * @since 2.0.0 + */ +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace Brand { + /** + * A utility type to extract the unbranded value type from a brand. + * + * @category utility types + * @since 2.0.0 + */ + export type Unbranded> = B extends (infer U) & Brands + ? U + : B; + + /** + * A utility type to extract the keys of a branded type. + * + * @category utility types + * @since 4.0.0 + */ + export type Keys> = keyof B[typeof TypeId]; + + type UnionToIntersection = ( + T extends any ? (x: T) => any : never + ) extends (x: infer R) => any + ? R + : never; + + /** + * A utility type to extract the brands from a branded type. + * + * @category utility types + * @since 2.0.0 + */ + export type Brands> = UnionToIntersection< + { [K in Keys]: K extends string ? Brand : never }[Keys] + >; +} + +/** + * A type alias for creating branded types more concisely. + * + * @category utility types + * @since 2.0.0 + */ +export type Branded = A & Brand; + +export const Branded = + >() => + (value: Brand.Unbranded): B => + value as B; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/dev-harness-controls-panel.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/dev-harness-controls-panel.tsx new file mode 100644 index 00000000000..89a9105f74e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/dev-harness-controls-panel.tsx @@ -0,0 +1,272 @@ +/** + * The floating controls panel for the dev harness: sliders for the fixture knobs, the streaming + * toggle and its chunk controls, and the Regenerate button. Pure presentation -- it reads the + * current knob values and reports changes up; the harness owns the state and the fixture. + */ +import { Box, Slider, Stack, Switch, Typography } from "@mui/material"; +import { memo, useState } from "react"; + +import { Button } from "../../../../shared/ui"; + +/** The full knob set the harness drives the generator with. */ +export interface HarnessKnobs { + readonly entityCount: number; + readonly entityTypeCount: number; + readonly linkDensity: number; + readonly rootFraction: number; + readonly hubCount: number; + // FA2 force tuning (community-force tier). + readonly fa2Gravity: number; + readonly fa2ScalingRatio: number; + readonly fa2LinLog: boolean; + readonly fa2StrongGravity: boolean; + readonly stream: boolean; + readonly chunkSize: number; + readonly intervalMs: number; +} + +interface ControlsPanelProps { + readonly knobs: HarnessKnobs; + readonly onChange: (knobs: HarnessKnobs) => void; + readonly onRegenerate: () => void; + /** Entities handed to the visualizer so far, shown against the total while streaming. */ + readonly streamedCount: number; + readonly totalCount: number; + readonly seed: number; +} + +interface KnobSliderProps { + readonly label: string; + readonly value: number; + readonly min: number; + readonly max: number; + readonly step: number; + readonly disabled?: boolean; + readonly onChange: (value: number) => void; +} + +const KnobSlider = memo( + ({ label, value, min, max, step, disabled, onChange }: KnobSliderProps) => ( + + + + {label} + + {value} + + { + onChange(Array.isArray(next) ? next[0]! : next); + }} + /> + + ), +); + +export const ControlsPanel = memo( + ({ + knobs, + onChange, + onRegenerate, + streamedCount, + totalCount, + seed, + }: ControlsPanelProps) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const set = (patch: Partial) => { + onChange({ ...knobs, ...patch }); + }; + + return ( + ({ + position: "absolute", + top: 16, + left: 16, + zIndex: 10, + width: isCollapsed ? 232 : 280, + maxHeight: "calc(100% - 32px)", + overflowY: "auto", + p: isCollapsed ? 1.25 : 2, + borderRadius: 2, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + boxShadow: boxShadows.md, + })} + > + + + + Graph visualizer dev harness + + + Seed {seed} - {streamedCount} / {totalCount} visible + + + + + + {isCollapsed ? null : ( + + set({ entityCount })} + /> + set({ entityTypeCount })} + /> + set({ linkDensity })} + /> + set({ rootFraction })} + /> + set({ hubCount })} + /> + + set({ fa2Gravity })} + /> + set({ fa2ScalingRatio })} + /> + + + FA2 LinLog mode + + set({ fa2LinLog: event.target.checked })} + /> + + + + FA2 strong gravity + + + set({ fa2StrongGravity: event.target.checked }) + } + /> + + + + + Stream incrementally + + set({ stream: event.target.checked })} + /> + + + {knobs.stream ? ( + <> + set({ chunkSize })} + /> + set({ intervalMs })} + /> + + Streamed {streamedCount} / {totalCount} + + + ) : null} + + + + )} + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-hover-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-hover-card.tsx new file mode 100644 index 00000000000..53f4dde5f81 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-hover-card.tsx @@ -0,0 +1,364 @@ +import { Box, Divider, Stack, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo, useMemo } from "react"; + +import { EntityOrTypeIcon } from "@hashintel/design-system"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; + +import { Button } from "../../../../shared/ui/button"; + +import type { + BaseUrl, + ClosedMultiEntityType, +} from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +/** How many salient properties the card shows before it gets noisy. */ +const MAX_PROPERTIES = 4; + +interface EntityHoverCardProps { + readonly entity: HashEntity; + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; + /** Incident-link count (needs the full entity set, so the bridge resolves it). */ + readonly degree: number; + /** Cursor / node position in the container's local pixels. */ + readonly x: number; + readonly y: number; + /** + * When set, the card is "pinned" (a selection, not a hover): it renders an Open action + * that calls this. The card body stays click-through; only this button is interactive. + * Must be referentially stable across pans, or the memoized body re-renders every frame. + */ + readonly onOpen?: () => void; +} + +/** A short, single-line rendering of a property value. */ +function formatPropertyValue(value: unknown): string { + if (Array.isArray(value)) { + return value.map((item) => formatPropertyValue(item)).join(", "); + } + if (typeof value === "string") { + return value.length > 64 ? `${value.slice(0, 63)}…` : value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return "…"; +} + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +type EntityHoverCardBodyProps = Omit; + +/** + * The card's visual body. Memoized on the entity + type context (NOT the position), so a pan + * that moves the card every frame only updates the wrapper's transform -- this MUI tree (label, + * type, properties, footer) is laid out once per entity, never re-rendered per frame. For that + * to hold, every prop here must be referentially stable while the selection is unchanged. + */ +const EntityHoverCardBodyComponent = ({ + entity, + closedMultiEntityTypesRootMap, + definitions, + degree, + onOpen, +}: EntityHoverCardBodyProps) => { + // Resolve everything from the entity + type context, keyed on the entity so a cursor move + // over the same dot reuses it (the position rides x/y separately). + const content = useMemo(() => { + if (!closedMultiEntityTypesRootMap) { + return null; + } + + let closedType: ClosedMultiEntityType; + try { + closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + entity.metadata.entityTypeIds, + ); + } catch { + return null; + } + + const { icon, isLink, labelProperty } = + getDisplayFieldsForClosedEntityType(closedType); + + const properties: { title: string; value: string }[] = []; + for (const [baseUrl, raw] of Object.entries(entity.properties)) { + if (baseUrl === labelProperty || raw === null) { + continue; + } + + const refSchema = closedType.properties[baseUrl as BaseUrl]; + if (!refSchema) { + continue; + } + + const propertyTypeId = + "$ref" in refSchema ? refSchema.$ref : refSchema.items.$ref; + + const title = definitions?.propertyTypes[propertyTypeId]?.title; + const value = formatPropertyValue(raw); + if (!title || value === "") { + continue; + } + properties.push({ title, value }); + if (properties.length >= MAX_PROPERTIES) { + break; + } + } + + const createdIso = entity.metadata.provenance.createdAtDecisionTime; + const createdDate = createdIso ? new Date(createdIso) : undefined; + const created = + createdDate && !Number.isNaN(createdDate.getTime()) + ? createdDate.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }) + : undefined; + + return { + label: generateEntityLabel(closedType, entity), + typeTitle: closedType.allOf[0].title, + icon, + isLink, + properties, + created, + }; + }, [entity, closedMultiEntityTypesRootMap, definitions]); + + if (!content) { + return null; + } + + // A link entity's "degree" (links pointing AT the link) is ~always 0 and only confuses, so + // the link count is shown for nodes only. + const hasFooter = + (degree > 0 && !content.isLink) || content.created !== undefined; + + return ( + ({ + position: "relative", + minWidth: 208, + maxWidth: 300, + bgcolor: onOpen ? palette.blue[10] : palette.common.white, + border: `1px solid ${onOpen ? palette.blue[30] : palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + {/* Identity: type icon anchors the name + type, so they read as one block. */} + + ({ + flexShrink: 0, + width: 32, + height: 32, + borderRadius: "7px", + bgcolor: palette.gray[10], + border: `1px solid ${palette.gray[20]}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + })} + > + palette.gray[70]} + /> + + + palette.gray[90], + wordBreak: "break-word", + }} + > + {content.label} + + palette.gray[70], + }} + > + {content.typeTitle} + + + + + {content.properties.length > 0 && ( + <> + palette.gray[20] }} /> + + {content.properties.map((property) => ( + + palette.gray[70], + }} + > + {property.title} + + palette.gray[90], + }} + > + {property.value} + + + ))} + + + )} + + {hasFooter && ( + <> + palette.gray[20] }} /> + + {!content.isLink && ( + palette.gray[70] }} + > + {degree} {degree === 1 ? "link" : "links"} + + )} + {content.created !== undefined && ( + palette.gray[70] }} + > + {content.created} + + )} + + + )} + + {onOpen ? ( + <> + palette.gray[20] }} /> + + + ) : null} + + ); +}; + +const EntityHoverCardBody = memo(EntityHoverCardBodyComponent); + +/** + * Hover / selection card for an entity dot. Owns how an entity is PRESENTED (label, type, a few + * key properties, a creation date) in the hash-frontend design language. The wrapper positions + * the card via a GPU transform -- cheap to update every pan frame -- while {@link + * EntityHoverCardBody} renders the contents, memoized so that per-frame move never re-lays them + * out. Click-through (pointer-events disabled) except the Open button, so it never eats hovers. + */ +export const EntityHoverCard = ({ + entity, + closedMultiEntityTypesRootMap, + definitions, + degree, + x, + y, + onOpen, +}: EntityHoverCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-label-overlay.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-label-overlay.tsx new file mode 100644 index 00000000000..5c6b971d444 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/entity-label-overlay.tsx @@ -0,0 +1,67 @@ +import { alpha, Box } from "@mui/material"; +import { memo } from "react"; + +import type { EntityLabel } from "../render/scene"; + +interface EntityLabelOverlayProps { + readonly labels: readonly EntityLabel[]; +} + +/** + * One hub label's pill, memoized on its text so a per-frame position change (the wrapper's + * transform) never re-lays out the text. Positioned absolutely at the wrapper's origin (which the + * Scene places to the RIGHT of the dot) and vertically centred on the dot via translateY(-50%), + * left-aligned -- so it reads "Name" beside the dot with the text start as the stable anchor. + */ +const HubLabel = memo(({ text }: { readonly text: string }) => ( + ({ + position: "absolute", + transform: "translateY(-50%)", + maxWidth: 180, + px: 0.75, + py: 0.2, + borderRadius: "5px", + bgcolor: alpha(palette.common.white, 0.88), + border: `1px solid ${palette.gray[20]}`, + boxShadow: boxShadows.sm, + typography: "microText", + fontWeight: 700, + letterSpacing: "-0.01em", + color: palette.gray[90], + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + })} + > + {text} + +)); + +/** + * The always-on hub labels, overlaid as HTML over the canvas (in the hash-frontend design + * language rather than GPU text). The Scene re-emits `labels` -- only the on-screen hubs, with + * their current container-pixel positions -- each frame, so they track the camera + settling + * layout. Each label rides a cheap GPU transform; the pill itself is memoized on its text. + * Click-through (pointer-events disabled) so it never eats a hover / pan on the canvas. + */ +export const EntityLabelOverlay = ({ labels }: EntityLabelOverlayProps) => ( + <> + {labels.map((label) => ( +
+ +
+ ))} + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-cluster-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-cluster-card.tsx new file mode 100644 index 00000000000..67201dc8b02 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-cluster-card.tsx @@ -0,0 +1,163 @@ +import { Box, Divider, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo } from "react"; + +import { Button } from "../../../../shared/ui/button"; +import { graphColors } from "../visual-style"; + +interface FrontierClusterCardProps { + /** Number of unexpanded (frontier) entities the cluster holds. */ + readonly count: number; + /** Bubble centre in container pixels. */ + readonly x: number; + readonly y: number; + /** Bubble on-screen radius (px); the card sits just outside the right edge. */ + readonly radiusPx: number; + /** A frontier fetch is already in flight, so the action is busy. */ + readonly isFetching: boolean; + readonly onLoad: () => void; + readonly onMouseEnter: () => void; + readonly onMouseLeave: () => void; +} + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +const [frontierR, frontierG, frontierB] = graphColors.frontier; + +type FrontierClusterBodyProps = Pick< + FrontierClusterCardProps, + "count" | "isFetching" | "onLoad" +>; + +/** + * The card's visual body, memoized on its content (NOT the bubble position), so the per-frame + * re-position of the wrapper never re-lays out this MUI tree. Echoes the grey frontier bubble with a + * matching swatch and follows the same icon + title + stat discipline as the other graph cards. + */ +const FrontierClusterBodyComponent = ({ + count, + isFetching, + onLoad, +}: FrontierClusterBodyProps) => ( + ({ + minWidth: 188, + maxWidth: 260, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + overflow: "hidden", + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + + {/* A swatch in the frontier grey, tying the card to the greyed-out bubble it acts on. */} + ({ + flexShrink: 0, + width: 26, + height: 26, + borderRadius: "50%", + bgcolor: `rgb(${frontierR}, ${frontierG}, ${frontierB})`, + border: `1px solid ${palette.gray[30]}`, + })} + /> + + palette.gray[90], + }} + > + Unexpanded cluster + + palette.gray[70], + }} + > + palette.gray[90] }} + > + {count.toLocaleString()} + {" "} + {count === 1 ? "entity" : "entities"} not loaded + + + + + palette.gray[20] }} /> + + + +); + +const FrontierClusterBody = memo(FrontierClusterBodyComponent); + +/** + * Action card for a wholly-frontier cluster bubble: its unexpanded entity count and a button that + * loads them. Unlike the click-through hover cards, this one is interactive (the Load button), so + * the bridge keeps it open while the cursor is over the bubble OR the card. The wrapper positions + * the card just outside the bubble's right edge via a GPU transform and re-positions every frame so + * it tracks the bubble; {@link FrontierClusterBody} is memoized so that move never re-lays it out. + */ +export const FrontierClusterCard = ({ + count, + x, + y, + radiusPx, + isFetching, + onLoad, + onMouseEnter, + onMouseLeave, +}: FrontierClusterCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-controls.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-controls.tsx new file mode 100644 index 00000000000..c93da47cb8f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-controls.tsx @@ -0,0 +1,167 @@ +import { Box, Tooltip, Typography, useTheme } from "@mui/material"; + +import { + LoadingSpinner, + MagnifyingGlassPlusLightIcon, +} from "@hashintel/design-system"; + +import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; + +interface FrontierControlsProps { + readonly frontierCount: number; + readonly isFetching: boolean; + readonly fetchedCount: number; + readonly totalToFetch: number; + readonly error?: string; + readonly onFetchCompleteFrontier: () => void; +} + +export const FrontierControls = ({ + frontierCount, + isFetching, + fetchedCount, + totalToFetch, + error, + onFetchCompleteFrontier, +}: FrontierControlsProps) => { + const theme = useTheme(); + + if (frontierCount === 0 && !isFetching && !error) { + return null; + } + + const remainingCount = Math.max(0, totalToFetch - fetchedCount); + const progress = + isFetching && totalToFetch > 0 + ? `${remainingCount.toLocaleString()} ${ + remainingCount === 1 ? "frontier" : "frontiers" + } in flight · ${fetchedCount.toLocaleString()} of ${totalToFetch.toLocaleString()} loaded` + : undefined; + const compactInFlightText = + isFetching && totalToFetch > 0 + ? remainingCount > 0 + ? `Fetching ${remainingCount.toLocaleString()} ${ + remainingCount === 1 ? "frontier" : "frontiers" + }` + : "Finishing frontier fetch" + : undefined; + const badgeCount = isFetching ? 0 : frontierCount; + const disabled = frontierCount === 0 || isFetching; + const tooltipTitle = + error ?? + progress ?? + (frontierCount > 0 + ? `Fetch ${frontierCount.toLocaleString()} frontier ${ + frontierCount === 1 ? "entity" : "entities" + }` + : "The visible frontier is fully fetched."); + + return ( + + {compactInFlightText ? ( + ({ + display: "flex", + alignItems: "center", + gap: 0.75, + height: 26, + px: 0.85, + borderRadius: "4px", + bgcolor: palette.gray[5], + border: `1px solid ${palette.gray[30]}`, + pointerEvents: "none", + color: palette.gray[70], + whiteSpace: "nowrap", + })} + > + + ({ + fontSize: 13, + color: palette.gray[70], + fontWeight: 500, + lineHeight: 1, + })} + > + {compactInFlightText} + + + ) : null} + + + ({ + background: "red.10", + border: `1px solid ${palette.red[30]}`, + color: "red.80", + }) + : undefined, + }} + > + {isFetching ? ( + + ) : ( + + )} + + {badgeCount > 0 ? ( + ({ + position: "absolute", + top: -5, + right: -5, + minWidth: 16, + height: 16, + px: 0.4, + borderRadius: 8, + border: `1px solid ${palette.white}`, + bgcolor: error ? "red.70" : "blue.70", + color: palette.white, + fontSize: 9, + fontWeight: 700, + lineHeight: "14px", + textAlign: "center", + pointerEvents: "none", + })} + > + {badgeCount > 99 ? "99+" : badgeCount.toLocaleString()} + + ) : null} + + + {error ? ( + + {error} + + ) : null} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-legend.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-legend.tsx new file mode 100644 index 00000000000..f43d120f95b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/frontier-legend.tsx @@ -0,0 +1,51 @@ +import { Box, Stack, Typography } from "@mui/material"; + +import { graphColors } from "../visual-style"; + +const rgba = (color: readonly [number, number, number, number]) => + `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`; + +export const FrontierLegend = () => ( + + + + + + Grey nodes are frontier entities + + + + ({ + width: 14, + height: 2, + borderRadius: 999, + bgcolor: palette.gray[50], + })} + /> + + Thick lanes bundle links + + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-controls.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-controls.tsx new file mode 100644 index 00000000000..517007137a0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-controls.tsx @@ -0,0 +1,51 @@ +import { Tooltip } from "@mui/material"; + +import { + MagnifyingGlassMinusLightIcon, + MagnifyingGlassPlusLightIcon, +} from "@hashintel/design-system"; + +import { ArrowsToLineRegularIcon } from "../../../../shared/icons/arrows-to-line-regular-icon"; +import { GrayToBlueIconButton } from "../../gray-to-blue-icon-button"; + +interface GraphControlsProps { + readonly onZoomIn: () => void; + readonly onZoomOut: () => void; + readonly onFitView: () => void; +} + +export const GraphControls = ({ + onZoomIn, + onZoomOut, + onFitView, +}: GraphControlsProps) => ( + <> + + + + + + + + + + + + + + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-guidance-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-guidance-card.tsx new file mode 100644 index 00000000000..b82a7c05ee7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-guidance-card.tsx @@ -0,0 +1,46 @@ +import { Stack, Typography } from "@mui/material"; + +import { Button } from "../../../../shared/ui/button"; +import { GraphOverlayPanel } from "./graph-overlay-panel"; + +interface GraphGuidanceCardProps { + readonly onDismiss: () => void; +} + +export const GraphGuidanceCard = ({ onDismiss }: GraphGuidanceCardProps) => ( + + + + + Explore relationships + + + Drag to pan, scroll to zoom, hover for details, and select a node to + focus its neighbours. + + + + + Grey nodes are frontier entities outside the current query. + + + Bundled links can be opened as a table. + + + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-overlay-panel.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-overlay-panel.tsx new file mode 100644 index 00000000000..5fbd6d39eed --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-overlay-panel.tsx @@ -0,0 +1,38 @@ +import { Box } from "@mui/material"; + +import type { ReactNode } from "react"; + +interface GraphOverlayPanelProps { + readonly children: ReactNode; + readonly placement?: "top-left" | "top-right" | "bottom-left"; + readonly interactive?: boolean; +} + +const placementSx = { + "top-left": { top: 8, left: 8 }, + "top-right": { top: 8, right: 13 }, + "bottom-left": { bottom: 8, left: 8 }, +} as const; + +export const GraphOverlayPanel = ({ + children, + placement = "top-left", + interactive = true, +}: GraphOverlayPanelProps) => ( + ({ + position: "absolute", + zIndex: 7, + maxWidth: 320, + p: 1.25, + borderRadius: 2, + border: `1px solid ${palette.gray[30]}`, + bgcolor: palette.white, + boxShadow: boxShadows.sm, + pointerEvents: interactive ? "auto" : "none", + ...placementSx[placement], + })} + > + {children} + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-status-overlay.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-status-overlay.tsx new file mode 100644 index 00000000000..0b3fe321822 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/graph-status-overlay.tsx @@ -0,0 +1,40 @@ +import { Stack, Typography } from "@mui/material"; + +import { LoadingSpinner } from "@hashintel/design-system"; + +import { GraphOverlayPanel } from "./graph-overlay-panel"; + +interface GraphStatusOverlayProps { + readonly title: string; + readonly description?: string; + readonly variant?: "loading" | "empty" | "error"; +} + +export const GraphStatusOverlay = ({ + title, + description, + variant = "loading", +}: GraphStatusOverlayProps) => ( + + + {variant === "loading" ? : null} + + + variant === "error" ? palette.red[80] : palette.gray[90], + }} + > + {title} + + {description ? ( + + {description} + + ) : null} + + + +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/highway-summary-card.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/highway-summary-card.tsx new file mode 100644 index 00000000000..34e1869df16 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/components/highway-summary-card.tsx @@ -0,0 +1,237 @@ +import { Box, Divider, Typography } from "@mui/material"; +import { keyframes } from "@mui/system"; +import { memo, useMemo } from "react"; + +import { EntityOrTypeIcon } from "@hashintel/design-system"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; + +import type { VersionedUrl } from "@blockprotocol/type-system"; +import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; + +interface HighwaySummaryCardProps { + /** The lane's single link type (a lane is single-type); null for a multi-type rollup. */ + readonly typeId: VersionedUrl | null; + /** Fallback display label, used for a rollup (no single type to resolve from the schema). */ + readonly typeLabel: string; + /** Total number of links the highway aggregates. */ + readonly count: number; + /** Net direction of the bundled links, relative to the highway's source -> target. */ + readonly direction: "forward" | "reverse" | "both"; + /** The closed type schema the main thread already holds -- resolves the icon + title. */ + readonly closedMultiEntityTypesRootMap: + | ClosedMultiEntityTypesRootMap + | undefined; + /** Cursor position in the container's local pixels. */ + readonly x: number; + readonly y: number; +} + +const rise = keyframes` + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +`; + +/** A short human phrase for a bundle's net direction. */ +function directionLabel( + direction: HighwaySummaryCardProps["direction"], +): string { + switch (direction) { + case "both": + return "Bidirectional"; + case "forward": + return "Outgoing"; + case "reverse": + return "Incoming"; + } +} + +type HighwaySummaryBodyProps = Omit; + +/** + * The card's visual body, memoized on its content (NOT the cursor position), so a mouse-move that + * re-positions the wrapper every frame never re-lays out this MUI tree. Resolves the link type's + * icon + title from the closed type schema the SAME way {@link EntityHoverCard} does (the + * hierarchy-walking {@link getDisplayFieldsForClosedEntityType}), and follows the SAME icon + + * title + subtitle + stat discipline, so the two hover surfaces read as one language. + */ +const HighwaySummaryBodyComponent = ({ + typeId, + typeLabel, + count, + direction, + closedMultiEntityTypesRootMap, +}: HighwaySummaryBodyProps) => { + const typeFields = useMemo(() => { + if (!typeId || !closedMultiEntityTypesRootMap) { + return null; + } + try { + const closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + [typeId], + ); + const { icon, isLink } = getDisplayFieldsForClosedEntityType(closedType); + const leaf = closedType.allOf[0]; + // A link type reads differently per direction: the forward title ("Has Member") vs the + // inverse title ("Member Of"). A lane is single-direction, so pick the matching one. + return { + icon, + isLink, + title: leaf.title, + inverseTitle: leaf.inverse?.title, + }; + } catch { + return null; + } + }, [typeId, closedMultiEntityTypesRootMap]); + + const title = typeFields + ? direction === "reverse" + ? (typeFields.inverseTitle ?? typeFields.title) + : typeFields.title + : typeLabel || "Links"; + + return ( + ({ + minWidth: 188, + maxWidth: 280, + bgcolor: palette.common.white, + border: `1px solid ${palette.gray[20]}`, + borderRadius: "8px", + boxShadow: boxShadows.md, + overflow: "hidden", + animation: `${rise} 130ms cubic-bezier(0.22, 1, 0.36, 1)`, + "@media (prefers-reduced-motion: reduce)": { animation: "none" }, + })} + > + {/* Identity: the link type's icon anchors the type + direction, mirroring the entity card. */} + + ({ + flexShrink: 0, + width: 32, + height: 32, + borderRadius: "7px", + bgcolor: palette.gray[10], + border: `1px solid ${palette.gray[20]}`, + display: "flex", + alignItems: "center", + justifyContent: "center", + })} + > + palette.gray[70]} + /> + + + palette.gray[90], + wordBreak: "break-word", + }} + > + {title} + + palette.gray[70], + }} + > + {directionLabel(direction)} + + + + + palette.gray[20] }} /> + + {/* Stat: how many links this ribbon aggregates -- the number a click expands into a table. */} + + palette.gray[70] }} + > + palette.gray[90] }} + > + {count.toLocaleString()} + {" "} + {count === 1 ? "link" : "links"} bundled + + + + palette.gray[20] }} /> + + + + Click to view links + + + + ); +}; + +const HighwaySummaryBody = memo(HighwaySummaryBodyComponent); + +/** + * Hover summary for an aggregated highway: the link type(s) it bundles, the net direction, and how + * many links (a click opens the full table). The wrapper positions the card via a GPU transform -- + * cheap per cursor-move frame -- while {@link HighwaySummaryBody} renders the contents, memoized so + * the per-frame move never re-lays them out. Click-through so it never eats a hover. + */ +export const HighwaySummaryCard = ({ + typeId, + typeLabel, + count, + direction, + closedMultiEntityTypesRootMap, + x, + y, +}: HighwaySummaryCardProps) => ( +
+ +
+); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/config.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/config.ts new file mode 100644 index 00000000000..fd2203688a3 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/config.ts @@ -0,0 +1,154 @@ +/** + * Optional FA2 force overrides for the community-force tier. Each field, when set, REPLACES the + * value inferSettings derives from node count; an unset field keeps inferSettings' value. + */ +export interface Fa2Tuning { + readonly gravity?: number; + readonly scalingRatio?: number; + readonly linLogMode?: boolean; + readonly strongGravityMode?: boolean; +} + +export interface VizConfig { + // Scale thresholds (non-link entity count). + readonly flatLayoutMaxNodes: number; + readonly flatLayoutExitNodes: number; + readonly communityColorMaxNodes: number; + readonly communityColorExitNodes: number; + + // Clustering thresholds. + readonly minStandaloneTypeSet: number; + readonly mergeJaccardMin: number; + readonly mergeSubsetJaccardMin: number; + readonly maxChildrenPerParent: number; + + // Sub-clustering. + readonly subclusterAboveCount: number; + readonly entityRevealMax: number; + readonly forceMaxNodes: number; + readonly communityWorkerNodeCap: number; + readonly communityMinSize: number; + readonly communityMaxSize: number; + + // Semantic zoom thresholds (fraction of viewport min dimension). + readonly openChildrenFraction: number; + readonly closeChildrenFraction: number; + readonly openEntitiesFraction: number; + readonly closeEntitiesFraction: number; + + // Embedding subdivision. + readonly embeddingProjectionDims: number; + readonly embeddingMaxK: number; + readonly embeddingTargetLeafFillRatio: number; + readonly embeddingClientNodeCap: number; + readonly embeddingMinConcentration: number; + + // Bubble ports. + readonly minPortSpacingPx: number; + readonly maxPortsPerCluster: number; + readonly portPaddingWorld: number; + readonly portTension: number; + + // Render budgets. + readonly maxRenderedClusters: number; + readonly maxRenderedEntities: number; + readonly maxRenderedEdges: number; + readonly maxParallelEdgeTypes: number; + + // Edge geometry. + readonly parallelEdgeSpacingPx: number; + readonly parallelEdgeCurvature: number; + readonly curveSegments: number; + + // Optional FA2 force overrides; unset keeps inferSettings' per-order values. + readonly fa2?: Fa2Tuning; + + /** Enable noisy worker diagnostics. Intended for local profiling/debug only. */ + readonly debug?: boolean; +} + +export const defaultVizConfig: VizConfig = { + flatLayoutMaxNodes: 200, + flatLayoutExitNodes: 250, + communityColorMaxNodes: 4000, + communityColorExitNodes: 5000, + + minStandaloneTypeSet: 25, + mergeJaccardMin: 0.25, + mergeSubsetJaccardMin: 0.15, + maxChildrenPerParent: 64, + + subclusterAboveCount: 500, + entityRevealMax: 500, + forceMaxNodes: 2_000, + communityWorkerNodeCap: 50_000, + communityMinSize: 20, + communityMaxSize: 500, + + openChildrenFraction: 0.35, + closeChildrenFraction: 0.25, + openEntitiesFraction: 0.45, + closeEntitiesFraction: 0.3, + + embeddingProjectionDims: 128, + embeddingMaxK: 32, + embeddingTargetLeafFillRatio: 0.75, + embeddingClientNodeCap: 25_000, + embeddingMinConcentration: 0.3, + + minPortSpacingPx: 12, + maxPortsPerCluster: 24, + portPaddingWorld: 0, + portTension: 0.4, + + maxRenderedClusters: 4_000, + maxRenderedEntities: 5_000, + maxRenderedEdges: 10_000, + maxParallelEdgeTypes: 5, + + parallelEdgeSpacingPx: 7, + parallelEdgeCurvature: 1.6, + curveSegments: 16, +}; + +/** + * Validate config invariants on worker init (spec §10). Throws on violation + * so a bad config fails loudly at startup rather than producing subtly wrong + * hysteresis or mode transitions later. + */ +export function validateConfig(config: VizConfig): void { + function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Invalid VizConfig: ${message}`); + } + } + + assert( + config.entityRevealMax <= config.forceMaxNodes, + `entityRevealMax (${config.entityRevealMax}) must be <= forceMaxNodes (${config.forceMaxNodes})`, + ); + assert( + config.flatLayoutMaxNodes < config.flatLayoutExitNodes, + "flatLayoutMaxNodes must be < flatLayoutExitNodes (hysteresis)", + ); + assert( + config.communityColorMaxNodes < config.communityColorExitNodes, + "communityColorMaxNodes must be < communityColorExitNodes (hysteresis)", + ); + assert( + config.flatLayoutExitNodes <= config.communityColorMaxNodes, + "flatLayoutExitNodes must be <= communityColorMaxNodes (no mode gap)", + ); + assert( + config.closeChildrenFraction < config.openChildrenFraction, + "closeChildrenFraction must be < openChildrenFraction (hysteresis)", + ); + assert( + config.closeEntitiesFraction < config.openEntitiesFraction, + "closeEntitiesFraction must be < openEntitiesFraction (hysteresis)", + ); + assert( + config.communityMinSize < config.communityMaxSize, + "communityMinSize must be < communityMaxSize", + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness.tsx new file mode 100644 index 00000000000..d84c5d16e98 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness.tsx @@ -0,0 +1,247 @@ +/** + * Dev harness for {@link EntityGraphVisualizerV2}: renders the visualizer against a synthetic + * fixture driven by UI knobs, so the visualizer can be iterated on without navigating to the + * entities page and creating real entities. + * + * Two feed modes: + * - all-at-once: every fixture entity is handed to the visualizer immediately. + * - streaming: entities are revealed in chunks over time (and `rootEntityIds` grows alongside), to + * reproduce the incremental/absorb path (FA2 settling, hub-labels during load). + */ +import { Box } from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { LoadingSpinner } from "@hashintel/design-system"; + +import { ControlsPanel } from "./components/dev-harness-controls-panel"; +import { defaultVizConfig } from "./config"; +import { generateGraphFixture } from "./dev-harness/generate-fixture"; +import { EntityGraphVisualizerV2 } from "./entity-graph-visualizer"; + +import type { HarnessKnobs } from "./components/dev-harness-controls-panel"; +import type { VizConfig } from "./config"; +import type { GraphFixture } from "./dev-harness/generate-fixture"; +import type { EntityId } from "@blockprotocol/type-system"; + +const DEFAULT_KNOBS: HarnessKnobs = { + entityCount: 200, + entityTypeCount: 4, + linkDensity: 1.2, + rootFraction: 0.7, + hubCount: 4, + // FA2 baseline matching the production default, so the initial view equals it. + fa2Gravity: 0.05, + fa2ScalingRatio: 10, + fa2LinLog: true, + fa2StrongGravity: false, + stream: false, + chunkSize: 10, + intervalMs: 150, +}; + +/** How much of the fixture is currently revealed to the visualizer. */ +interface FeedState { + /** Count of node entities revealed; roots grow with this count when streaming. */ + readonly revealedNodes: number; + /** Count of link entities revealed. */ + readonly revealedLinks: number; +} + +export const DevHarness = () => { + const [knobs, setKnobs] = useState(DEFAULT_KNOBS); + const [seed, setSeed] = useState(1); + + // The fixture is regenerated only when a fixture-shaping knob (or seed) changes; the streaming + // knobs (chunk size, interval) drive the feed, not the fixture. + const fixture = useMemo( + () => + generateGraphFixture({ + entityCount: knobs.entityCount, + entityTypeCount: knobs.entityTypeCount, + linkDensity: knobs.linkDensity, + rootFraction: knobs.rootFraction, + hubCount: knobs.hubCount, + seed, + }), + [ + knobs.entityCount, + knobs.entityTypeCount, + knobs.linkDensity, + knobs.rootFraction, + knobs.hubCount, + seed, + ], + ); + + // Separate nodes from links so streaming can reveal nodes first, then their links, and grow the + // root set against the revealed nodes (links are never roots). + const { nodes, links, nodeIdOrder } = useMemo(() => { + const nodeList = fixture.entities.filter( + (entity) => entity.linkData === undefined, + ); + const linkList = fixture.entities.filter( + (entity) => entity.linkData !== undefined, + ); + return { + nodes: nodeList, + links: linkList, + nodeIdOrder: nodeList.map((entity) => entity.metadata.recordId.entityId), + }; + }, [fixture]); + + const [feed, setFeed] = useState({ + revealedNodes: 0, + revealedLinks: 0, + }); + + // Reset the feed whenever the fixture or feed mode changes: all-at-once reveals everything, + // streaming starts from zero and grows via the interval below. + useEffect(() => { + if (knobs.stream) { + setFeed({ revealedNodes: 0, revealedLinks: 0 }); + } else { + setFeed({ revealedNodes: nodes.length, revealedLinks: links.length }); + } + }, [knobs.stream, nodes.length, links.length]); + + // Keep the latest chunk size in a ref so the interval reads it without re-subscribing each change. + const chunkSizeRef = useRef(knobs.chunkSize); + chunkSizeRef.current = knobs.chunkSize; + + // Streaming reveal: every `intervalMs`, reveal another chunk of nodes (then links once nodes are + // exhausted), until the whole fixture is shown. Re-armed on fixture / interval / mode change. + useEffect(() => { + if (!knobs.stream) { + return; + } + const handle = setInterval(() => { + setFeed((previous) => { + const chunk = chunkSizeRef.current; + if (previous.revealedNodes < nodes.length) { + return { + ...previous, + revealedNodes: Math.min( + nodes.length, + previous.revealedNodes + chunk, + ), + }; + } + if (previous.revealedLinks < links.length) { + return { + ...previous, + revealedLinks: Math.min( + links.length, + previous.revealedLinks + chunk, + ), + }; + } + return previous; + }); + }, knobs.intervalMs); + return () => { + clearInterval(handle); + }; + }, [knobs.stream, knobs.intervalMs, nodes.length, links.length]); + + // The entities + roots actually handed to the visualizer this render, sliced to the revealed + // counts. The visualizer's ingest is additive (it only sends the delta), so growing these arrays + // reproduces the incremental absorb path. + const { visibleEntities, visibleRootIds } = useMemo(() => { + const revealedNodes = nodes.slice(0, feed.revealedNodes); + const revealedLinks = links.slice(0, feed.revealedLinks); + const revealedNodeIds = new Set(nodeIdOrder.slice(0, feed.revealedNodes)); + const roots = fixture.rootEntityIds.filter((id) => revealedNodeIds.has(id)); + const safeRoots = + roots.length > 0 + ? roots + : revealedNodes + .slice(0, 1) + .map((entity) => entity.metadata.recordId.entityId); + return { + visibleEntities: [...revealedNodes, ...revealedLinks], + visibleRootIds: safeRoots, + }; + }, [nodes, links, nodeIdOrder, feed, fixture.rootEntityIds]); + + const handleRegenerate = useCallback(() => { + setSeed((previous) => previous + 1); + }, []); + + // Frontier expand fetches against the live API; with synthetic ids that fetch no-ops (the dev + // entities do not exist server-side), so clicking a frontier node simply logs and does nothing + // further -- that is expected here. + const handleEntityClick = useCallback((entityId: EntityId) => { + // eslint-disable-next-line no-console -- dev harness affordance + console.log("dev-harness onEntityClick", entityId); + }, []); + + const handleOpenLinkTable = useCallback( + (linkEntityIds: readonly EntityId[]) => { + // eslint-disable-next-line no-console -- dev harness affordance + console.log("dev-harness onOpenLinkTable", linkEntityIds); + }, + [], + ); + + // Remount the visualizer (fresh worker, cleared graph) whenever the fixture identity or feed mode + // changes. Ingest is additive, so without a remount a regenerated fixture would pile its entities + // onto the previous graph instead of replacing it -- so the key must include EVERY fixture-shaping + // knob, not just seed/stream (changing any of these sliders regenerates the fixture too). + const visualizerKey = [ + seed, + knobs.entityCount, + knobs.entityTypeCount, + knobs.linkDensity, + knobs.rootFraction, + knobs.hubCount, + knobs.fa2Gravity, + knobs.fa2ScalingRatio, + knobs.fa2LinLog, + knobs.fa2StrongGravity, + knobs.stream, + ].join(":"); + + // Default scale/cluster config plus the FA2 force overrides from the knobs. A change to any FA2 + // knob also changes the key above, so the worker remounts and re-inits with the new forces. + const layoutConfig = useMemo( + () => ({ + ...defaultVizConfig, + fa2: { + gravity: knobs.fa2Gravity, + scalingRatio: knobs.fa2ScalingRatio, + linLogMode: knobs.fa2LinLog, + strongGravityMode: knobs.fa2StrongGravity, + }, + }), + [ + knobs.fa2Gravity, + knobs.fa2ScalingRatio, + knobs.fa2LinLog, + knobs.fa2StrongGravity, + ], + ); + + return ( + + + } + onEntityClick={handleEntityClick} + onOpenLinkTable={handleOpenLinkTable} + /> + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.test.ts new file mode 100644 index 00000000000..6b47ef7eed5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.test.ts @@ -0,0 +1,93 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { expect, test } from "vitest"; + +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; + +import { generateGraphFixture } from "./generate-fixture"; + +const params = { + entityCount: 40, + entityTypeCount: 4, + linkDensity: 1.5, + rootFraction: 0.6, + hubCount: 3, + seed: 12345, +} as const; + +test("the produced root map resolves every entity via getClosedMultiEntityTypeFromMap", () => { + const { entities, closedMultiEntityTypesRootMap } = + generateGraphFixture(params); + + for (const entity of entities) { + expect(() => + getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + entity.metadata.entityTypeIds, + ), + ).not.toThrow(); + } +}); + +test("a generated entity yields a non-empty label and an emoji icon", () => { + const { entities, closedMultiEntityTypesRootMap } = + generateGraphFixture(params); + + const node = entities.find((entity) => entity.linkData === undefined); + expect(node).toBeDefined(); + + const closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + node!.metadata.entityTypeIds, + ); + + const label = generateEntityLabel(closedType, node!); + expect(label.length).toBeGreaterThan(0); + // The label resolves via the type's labelProperty, not the entity-type-title fallback. + expect(label).not.toMatch(/^Entity/); + + const { icon, labelProperty } = + getDisplayFieldsForClosedEntityType(closedType); + expect(typeof icon).toBe("string"); + expect(icon?.length ?? 0).toBeGreaterThan(0); + expect(labelProperty).toBeDefined(); +}); + +test("regenerate with the same seed reproduces the same graph", () => { + const first = generateGraphFixture(params); + const second = generateGraphFixture(params); + + expect(first.entities.map((entity) => entity.entityId)).toEqual( + second.entities.map((entity) => entity.entityId), + ); + expect(first.rootEntityIds).toEqual(second.rootEntityIds); +}); + +test("hubs emerge: a few nodes carry far more incident links than the median", () => { + const { entities } = generateGraphFixture({ ...params, hubCount: 3 }); + + const degree = new Map(); + for (const entity of entities) { + const { linkData } = entity; + if (!linkData) { + continue; + } + degree.set( + linkData.leftEntityId, + (degree.get(linkData.leftEntityId) ?? 0) + 1, + ); + degree.set( + linkData.rightEntityId, + (degree.get(linkData.rightEntityId) ?? 0) + 1, + ); + } + + const degrees = [...degree.values()].sort((a, b) => b - a); + expect(degrees.length).toBeGreaterThan(0); + const maxDegree = degrees[0]!; + const median = degrees[Math.floor(degrees.length / 2)] ?? 0; + expect(maxDegree).toBeGreaterThan(median); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.ts new file mode 100644 index 00000000000..9ec0131922f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture.ts @@ -0,0 +1,70 @@ +/** + * Deterministic synthetic-graph generator for the dev harness: from a small set of knobs it builds a + * valid {@link HashEntity} set plus the ontology maps the visualizer needs, so a developer can + * iterate on the visualizer without real entities. + * + * Reproducible per seed (a seeded PRNG drives every choice), so Regenerate with the same seed yields + * the same graph. + */ +import { buildEntities } from "./generate-fixture/build-entities"; +import { buildTypeMaps } from "./generate-fixture/build-type-maps"; + +import type { EntityId } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +/** Knobs that shape the generated graph. All counts are clamped to sane minimums. */ +export interface GenerateFixtureParams { + /** Number of normal (non-link) entities. */ + readonly entityCount: number; + /** Number of distinct entity types, clamped to the built-in kind list. */ + readonly entityTypeCount: number; + /** Average links per node; total link entities is roughly `entityCount * linkDensity`. */ + readonly linkDensity: number; + /** Fraction (0-1) of nodes treated as query roots; the rest render as frontier nodes. */ + readonly rootFraction: number; + /** Count of high-degree hub nodes that many others link into. */ + readonly hubCount: number; + /** Seed for the PRNG; the same seed reproduces the same graph. */ + readonly seed: number; +} + +/** The complete set of props the visualizer consumes, ready to spread onto it. */ +export interface GraphFixture { + readonly entities: HashEntity[]; + readonly rootEntityIds: EntityId[]; + readonly closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; + readonly definitions: ClosedMultiEntityTypesDefinitions; +} + +export function generateGraphFixture( + params: GenerateFixtureParams, +): GraphFixture { + const { kinds, linkTypeId, closedMultiEntityTypesRootMap, definitions } = + buildTypeMaps(params.entityTypeCount); + + const { entities, nodeEntityIds } = buildEntities({ + entityCount: params.entityCount, + kinds, + linkTypeId, + linkDensity: params.linkDensity, + hubCount: params.hubCount, + seed: params.seed, + }); + + // Roots are the first `rootFraction` of the node ids; ordering is stable per seed, so streaming + // can grow the root set alongside the entity stream. Hubs come first, so they are roots early. + const clampedFraction = Math.min(1, Math.max(0, params.rootFraction)); + const rootCount = Math.round(nodeEntityIds.length * clampedFraction); + const rootEntityIds = nodeEntityIds.slice(0, rootCount); + + return { + entities, + rootEntityIds, + closedMultiEntityTypesRootMap, + definitions, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts new file mode 100644 index 00000000000..aa4848f6eb2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts @@ -0,0 +1,237 @@ +/** + * Build the synthetic {@link HashEntity} set: normal entities (one per kind, with a populated label + * property so `generateEntityLabel` resolves a name) plus link entities connecting them with a + * hub-skewed degree distribution, so a few high-degree hubs emerge and the hub-label feature is + * exercised. + * + * Entities are constructed by mirroring the Graph-API plain-object shape (recordId.entityId in + * `webId~entityUuid` form, non-empty entityTypeIds, provenance, temporalVersioning, per-property + * `metadata.dataTypeId`) and wrapping it in `new HashEntity(...)`. + */ +import { HashEntity } from "@local/hash-graph-sdk/entity"; + +import { mulberry32 } from "../../worker/random"; + +import type { FixtureEntityKind } from "./build-type-maps"; +import type { + BaseUrl, + EntityId, + VersionedUrl, +} from "@blockprotocol/type-system"; +import type { Entity as GraphApiEntity } from "@local/hash-graph-client"; + +const WEB_ID = "00000000-0000-0000-0000-000000000001"; + +const TEXT_DATA_TYPE_ID = + "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1"; + +const LINK_LABEL_BASE_URL = + "https://hash.ai/@dev/types/property-type/relationship-label/" as BaseUrl; + +const FIXED_TIMESTAMP = "2024-01-01T00:00:00Z"; +const ACTOR_ID = "4ed14962-7132-4453-8fc5-39b5c2131d45"; + +/** Inputs for {@link buildEntities}. */ +export interface BuildEntitiesParams { + readonly entityCount: number; + readonly kinds: readonly FixtureEntityKind[]; + readonly linkTypeId: VersionedUrl; + /** Average links emitted per node; total links is roughly `entityCount * linkDensity`. */ + readonly linkDensity: number; + /** Count of high-degree hub nodes that many other nodes link into. */ + readonly hubCount: number; + readonly seed: number; +} + +/** The built entity set plus the ordered ids, so the harness can stream and pick roots in order. */ +export interface EntityFixture { + readonly entities: HashEntity[]; + readonly nodeEntityIds: readonly EntityId[]; +} + +/** A 16-hex-digit segment of a deterministic, fixture-shaped UUID. */ +function hexSegment(rng: () => number, length: number): string { + let out = ""; + for (let index = 0; index < length; index++) { + out += Math.floor(rng() * 16).toString(16); + } + return out; +} + +function makeUuid(rng: () => number): string { + return [ + hexSegment(rng, 8), + hexSegment(rng, 4), + hexSegment(rng, 4), + hexSegment(rng, 4), + hexSegment(rng, 12), + ].join("-"); +} + +function makeEntityId(rng: () => number): EntityId { + return `${WEB_ID}~${makeUuid(rng)}` as EntityId; +} + +/** Provenance block shared by every synthetic entity. */ +function provenance(): GraphApiEntity["metadata"]["provenance"] { + return { + createdById: ACTOR_ID, + createdAtDecisionTime: FIXED_TIMESTAMP, + createdAtTransactionTime: FIXED_TIMESTAMP, + firstNonDraftCreatedAtDecisionTime: FIXED_TIMESTAMP, + firstNonDraftCreatedAtTransactionTime: FIXED_TIMESTAMP, + edition: { + createdById: ACTOR_ID, + actorType: "machine", + origin: { type: "api" }, + }, + }; +} + +/** Temporal versioning block (decision + transaction time, both unbounded) shared by every entity. */ +function temporalVersioning(): GraphApiEntity["metadata"]["temporalVersioning"] { + const interval = { + start: { kind: "inclusive" as const, limit: FIXED_TIMESTAMP }, + end: { kind: "unbounded" as const }, + }; + return { decisionTime: interval, transactionTime: interval }; +} + +/** + * A single text property's flat value plus the matching nested metadata entry (carrying the + * dataTypeId), so the entity exposes both `entity.properties[baseUrl]` (read by the label path) and + * the per-property `metadata`. + */ +function textProperty(baseUrl: BaseUrl, value: string) { + return { + flat: { [baseUrl]: value }, + metadata: { + [baseUrl]: { + metadata: { dataTypeId: TEXT_DATA_TYPE_ID }, + }, + }, + }; +} + +function buildNode(params: { + readonly entityId: EntityId; + readonly editionId: string; + readonly kind: FixtureEntityKind; + readonly label: string; +}): GraphApiEntity { + const name = textProperty(params.kind.labelPropertyBaseUrl, params.label); + const description = textProperty( + params.kind.secondaryPropertyBaseUrl, + `A synthetic ${params.kind.name.toLowerCase()} for the dev harness.`, + ); + + return { + metadata: { + archived: false, + entityTypeIds: [params.kind.typeId], + provenance: provenance(), + temporalVersioning: temporalVersioning(), + recordId: { editionId: params.editionId, entityId: params.entityId }, + properties: { value: { ...name.metadata, ...description.metadata } }, + }, + properties: { ...name.flat, ...description.flat }, + }; +} + +function buildLink(params: { + readonly entityId: EntityId; + readonly editionId: string; + readonly linkTypeId: VersionedUrl; + readonly leftEntityId: EntityId; + readonly rightEntityId: EntityId; + readonly label: string; +}): GraphApiEntity { + const labelProperty = textProperty(LINK_LABEL_BASE_URL, params.label); + + return { + metadata: { + archived: false, + entityTypeIds: [params.linkTypeId], + provenance: provenance(), + temporalVersioning: temporalVersioning(), + recordId: { editionId: params.editionId, entityId: params.entityId }, + properties: { value: { ...labelProperty.metadata } }, + }, + properties: { ...labelProperty.flat }, + linkData: { + leftEntityId: params.leftEntityId, + rightEntityId: params.rightEntityId, + }, + }; +} + +/** + * Pick a link target index with a hub bias: most targets are drawn from the first `hubCount` nodes, + * the rest uniformly. This concentrates incoming links on a few hubs so the 95th-percentile hub + * detection promotes them. + */ +function pickTarget( + rng: () => number, + nodeCount: number, + hubCount: number, +): number { + const effectiveHubs = Math.min(hubCount, nodeCount); + if (effectiveHubs > 0 && rng() < 0.7) { + return Math.floor(rng() * effectiveHubs); + } + return Math.floor(rng() * nodeCount); +} + +export function buildEntities(params: BuildEntitiesParams): EntityFixture { + const rng = mulberry32(params.seed); + const nodeCount = Math.max(1, params.entityCount); + + const nodes: GraphApiEntity[] = []; + const nodeIds: EntityId[] = []; + const perKindCounter = new Map(); + + for (let index = 0; index < nodeCount; index++) { + const kind = params.kinds[index % params.kinds.length]!; + const ordinal = (perKindCounter.get(kind.typeId) ?? 0) + 1; + perKindCounter.set(kind.typeId, ordinal); + + const entityId = makeEntityId(rng); + nodeIds.push(entityId); + nodes.push( + buildNode({ + entityId, + editionId: makeUuid(rng), + kind, + label: `${kind.name} ${ordinal}`, + }), + ); + } + + const links: GraphApiEntity[] = []; + const targetLinkCount = Math.round( + nodeCount * Math.max(0, params.linkDensity), + ); + + for (let index = 0; index < targetLinkCount; index++) { + const sourceIndex = Math.floor(rng() * nodeCount); + let targetIndex = pickTarget(rng, nodeCount, params.hubCount); + if (targetIndex === sourceIndex) { + targetIndex = (targetIndex + 1) % nodeCount; + } + + links.push( + buildLink({ + entityId: makeEntityId(rng), + editionId: makeUuid(rng), + linkTypeId: params.linkTypeId, + leftEntityId: nodeIds[sourceIndex]!, + rightEntityId: nodeIds[targetIndex]!, + label: "related to", + }), + ); + } + + const entities = [...nodes, ...links].map((plain) => new HashEntity(plain)); + + return { entities, nodeEntityIds: nodeIds }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-type-maps.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-type-maps.ts new file mode 100644 index 00000000000..5070d8224d7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-type-maps.ts @@ -0,0 +1,254 @@ +/** + * Build the synthetic ontology the visualizer needs to label, icon, and card every fixture entity: + * one entity type per "kind" plus a single link type, the matching property types and data type, and + * the nested {@link ClosedMultiEntityTypesRootMap} keyed by sorted type URL that + * {@link getClosedMultiEntityTypeFromMap} traverses. + * + * Each kind is single-type, so its root-map entry is `{ [typeId]: { schema, inner: undefined } }`. + */ +import type { + BaseUrl, + ClosedDataType, + ClosedEntityTypeMetadata, + ClosedMultiEntityType, + EntityTypeDisplayMetadata, + PartialEntityType, + PropertyType, + VersionedUrl, +} from "@blockprotocol/type-system"; +import type { + ClosedDataTypeDefinition, + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; + +const WEB_SHORTNAME = "dev"; + +/** + * Display data for one synthetic entity kind. `name` is the kind's title; `icon` is its emoji, + * surfaced as the dot's type icon and the hover card's icon. + */ +export interface FixtureEntityKind { + readonly typeId: VersionedUrl; + readonly name: string; + readonly icon: string; + readonly labelPropertyBaseUrl: BaseUrl; + readonly labelPropertyTypeId: VersionedUrl; + readonly secondaryPropertyBaseUrl: BaseUrl; + readonly secondaryPropertyTypeId: VersionedUrl; +} + +/** + * The full type fixture: the per-kind display data the entity builder reads to populate properties, + * plus the link type id and the two map structures the visualizer consumes. + */ +export interface TypeFixture { + readonly kinds: readonly FixtureEntityKind[]; + readonly linkTypeId: VersionedUrl; + readonly closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap; + readonly definitions: ClosedMultiEntityTypesDefinitions; +} + +const TEXT_DATA_TYPE_ID = + "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" as VersionedUrl; + +const LINK_LABEL_BASE_URL = + "https://hash.ai/@dev/types/property-type/relationship-label/" as BaseUrl; + +const LINK_LABEL_PROPERTY_TYPE_ID = + "https://hash.ai/@dev/types/property-type/relationship-label/v/1" as VersionedUrl; + +const KIND_TITLES: readonly { + readonly title: string; + readonly icon: string; +}[] = [ + { title: "Person", icon: "\u{1F464}" }, + { title: "Company", icon: "\u{1F3E2}" }, + { title: "Project", icon: "\u{1F4C1}" }, + { title: "Document", icon: "\u{1F4C4}" }, + { title: "Event", icon: "\u{1F4C5}" }, + { title: "Location", icon: "\u{1F4CD}" }, + { title: "Product", icon: "\u{1F4E6}" }, + { title: "Team", icon: "\u{1F465}" }, +]; + +function slug(title: string): string { + return title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +function entityTypeId(title: string): VersionedUrl { + return `https://hash.ai/@${WEB_SHORTNAME}/types/entity-type/${slug(title)}/v/1` as VersionedUrl; +} + +function propertyBaseUrl(typeTitle: string, propertyName: string): BaseUrl { + return `https://hash.ai/@${WEB_SHORTNAME}/types/property-type/${slug(typeTitle)}-${slug(propertyName)}/` as BaseUrl; +} + +function propertyTypeId(typeTitle: string, propertyName: string): VersionedUrl { + return `${propertyBaseUrl(typeTitle, propertyName)}v/1` as VersionedUrl; +} + +/** A minimal single-text-value property type. */ +function makePropertyType($id: VersionedUrl, title: string): PropertyType { + return { + $schema: + "https://blockprotocol.org/types/modules/graph/0.3/schema/property-type", + kind: "propertyType", + $id, + title, + description: `Synthetic ${title} property for the dev harness.`, + oneOf: [{ $ref: TEXT_DATA_TYPE_ID }], + }; +} + +/** A minimal closed text data type (one string value constraint). */ +function makeTextDataType(): ClosedDataType { + return { + $id: TEXT_DATA_TYPE_ID, + title: "Text", + description: "An ordered sequence of characters.", + allOf: [{ type: "string" }], + abstract: false, + }; +} + +/** Build one closed-multi-entity-type wrapping a single closed entity type's display metadata. */ +function makeClosedMultiEntityType(params: { + readonly typeId: VersionedUrl; + readonly title: string; + readonly icon: string; + readonly labelPropertyBaseUrl: BaseUrl; + readonly properties: ClosedMultiEntityType["properties"]; + readonly isLink: boolean; +}): ClosedMultiEntityType { + const display: EntityTypeDisplayMetadata = { + $id: params.typeId, + depth: 0, + icon: params.icon, + labelProperty: params.labelPropertyBaseUrl, + }; + + const metadata: ClosedEntityTypeMetadata = { + $id: params.typeId, + title: params.title, + description: `Synthetic ${params.title} type for the dev harness.`, + allOf: [display], + ...(params.isLink ? { inverse: { title: `Inverse ${params.title}` } } : {}), + }; + + return { + properties: params.properties, + allOf: [metadata], + }; +} + +/** + * Construct the type fixture for `kindCount` entity kinds (clamped to the built-in title list) plus + * one shared link type. Returns the kinds (for the entity builder) and both visualizer map + * structures. + */ +export function buildTypeMaps(kindCount: number): TypeFixture { + const clamped = Math.max(1, Math.min(kindCount, KIND_TITLES.length)); + + const dataTypes: Record = { + [TEXT_DATA_TYPE_ID]: { schema: makeTextDataType(), parents: [] }, + }; + const propertyTypes: Record = {}; + const entityTypes: Record = {}; + const closedMultiEntityTypesRootMap: ClosedMultiEntityTypesRootMap = {}; + + const kinds: FixtureEntityKind[] = []; + + for (let index = 0; index < clamped; index++) { + const { title, icon } = KIND_TITLES[index]!; + const typeId = entityTypeId(title); + + const labelPropertyBaseUrl = propertyBaseUrl(title, "name"); + const labelPropertyTypeId = propertyTypeId(title, "name"); + const secondaryPropertyBaseUrl = propertyBaseUrl(title, "description"); + const secondaryPropertyTypeId = propertyTypeId(title, "description"); + + propertyTypes[labelPropertyTypeId] = makePropertyType( + labelPropertyTypeId, + `${title} Name`, + ); + propertyTypes[secondaryPropertyTypeId] = makePropertyType( + secondaryPropertyTypeId, + `${title} Description`, + ); + + const properties: ClosedMultiEntityType["properties"] = { + [labelPropertyBaseUrl]: { $ref: labelPropertyTypeId }, + [secondaryPropertyBaseUrl]: { $ref: secondaryPropertyTypeId }, + }; + + entityTypes[typeId] = { + $id: typeId, + title, + description: `Synthetic ${title} type for the dev harness.`, + labelProperty: labelPropertyBaseUrl, + icon, + }; + + closedMultiEntityTypesRootMap[typeId] = { + schema: makeClosedMultiEntityType({ + typeId, + title, + icon, + labelPropertyBaseUrl, + properties, + isLink: false, + }), + }; + + kinds.push({ + typeId, + name: title, + icon, + labelPropertyBaseUrl, + labelPropertyTypeId, + secondaryPropertyBaseUrl, + secondaryPropertyTypeId, + }); + } + + const linkTitle = "Related To"; + const linkTypeId = entityTypeId(linkTitle); + const linkIcon = "\u{1F517}"; + + propertyTypes[LINK_LABEL_PROPERTY_TYPE_ID] = makePropertyType( + LINK_LABEL_PROPERTY_TYPE_ID, + "Relationship Label", + ); + + const linkProperties: ClosedMultiEntityType["properties"] = { + [LINK_LABEL_BASE_URL]: { $ref: LINK_LABEL_PROPERTY_TYPE_ID }, + }; + + entityTypes[linkTypeId] = { + $id: linkTypeId, + title: linkTitle, + description: "Synthetic link type for the dev harness.", + labelProperty: LINK_LABEL_BASE_URL, + icon: linkIcon, + inverse: { title: "Related From" }, + }; + + closedMultiEntityTypesRootMap[linkTypeId] = { + schema: makeClosedMultiEntityType({ + typeId: linkTypeId, + title: linkTitle, + icon: linkIcon, + labelPropertyBaseUrl: LINK_LABEL_BASE_URL, + properties: linkProperties, + isLink: true, + }), + }; + + return { + kinds, + linkTypeId, + closedMultiEntityTypesRootMap, + definitions: { dataTypes, propertyTypes, entityTypes }, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dim-color.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dim-color.ts new file mode 100644 index 00000000000..fae171da154 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dim-color.ts @@ -0,0 +1,20 @@ +/** + * The single focus-dim formula, shared by the worker (entity dots + edge beziers, dimmed in + * the SABs / bezier buffers) and the main thread (cluster bubbles, dimmed in the layer) so + * everything recedes by exactly the same amount when a highlight is active. Desaturates toward + * a neutral grey and drops alpha, so a non-highlighted element fades into the background. + */ +import type { Color } from "./frames"; + +const DIM_GRAY = 150; +const DIM_MIX = 0.8; +const DIM_ALPHA = 0.2; + +export function dimColor(color: Color): Color { + return [ + Math.round(color[0] + (DIM_GRAY - color[0]) * DIM_MIX), + Math.round(color[1] + (DIM_GRAY - color[1]) * DIM_MIX), + Math.round(color[2] + (DIM_GRAY - color[2]) * DIM_MIX), + Math.round(color[3] * DIM_ALPHA), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx new file mode 100644 index 00000000000..5350b59c5b0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx @@ -0,0 +1,802 @@ +/** + * Drop-in replacement for EntityGraphVisualizer that uses the + * Deck.gl-based graph visualization (v2). + * + * Bridges HashEntity data into the worker's IngestEntity format + * and renders cluster bubbles. + */ +import { Box } from "@mui/material"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { extractBaseUrl } from "@blockprotocol/type-system"; +import { + getClosedMultiEntityTypeFromMap, + getDisplayFieldsForClosedEntityType, +} from "@local/hash-graph-sdk/entity"; +import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entity-label"; + +import { EntityHoverCard } from "./components/entity-hover-card"; +import { EntityLabelOverlay } from "./components/entity-label-overlay"; +import { FrontierClusterCard } from "./components/frontier-cluster-card"; +import { FrontierControls } from "./components/frontier-controls"; +import { FrontierLegend } from "./components/frontier-legend"; +import { GraphGuidanceCard } from "./components/graph-guidance-card"; +import { GraphStatusOverlay } from "./components/graph-status-overlay"; +import { HighwaySummaryCard } from "./components/highway-summary-card"; +import { fetchFrontierExpansion } from "./fetch-frontier-expansion"; +import { GraphVisualizerV2 } from "./graph-visualizer"; +import { + freshFrontierIds, + frontierExpansionBatches, +} from "./interactivity/frontier-expansion"; +import { useGraphGuidanceDismissal } from "./interactivity/use-graph-guidance-dismissal"; +import { useGraphWorker } from "./render/use-graph-worker"; + +import type { VizConfig } from "./config"; +import type { + ClusterHover, + EntityLabel, + EntitySelection, + HighwayHover, +} from "./render/scene"; +import type { + IngestEntity, + PropertySchemaEntry, + TypeSchemaEntry, +} from "./worker/protocol"; +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; +import type { ReactElement } from "react"; + +interface EntityGraphVisualizerV2Props { + readonly entities?: HashEntity[]; + /** + * EntityIds of the query ROOTS. Any entity in `entities` not in this set is a FRONTIER node -- a + * fetched link endpoint rendered greyed-out until expanded. Omit to treat every entity as a root + * (no frontier). + */ + readonly rootEntityIds?: readonly EntityId[]; + readonly closedMultiEntityTypesRootMap?: ClosedMultiEntityTypesRootMap; + // The full type-definition map (every referenced type, including inherited + // ancestors, with titles). Required so the worker learns about parent types + // that no entity uses directly — see {@link extractTypeSchemas}. + readonly definitions?: ClosedMultiEntityTypesDefinitions; + readonly loadingComponent: ReactElement; + /** Open an entity, wired to clicking a flat-tier dot (resolved via the join key). */ + readonly onEntityClick?: (entityId: EntityId) => void; + /** Open the underlying link entities of an aggregated "highway" edge in a table. */ + readonly onOpenLinkTable?: (linkEntityIds: readonly EntityId[]) => void; + /** Override the worker's layout/scale config; omit to use the defaults. */ + readonly config?: VizConfig; + /** + * A stable identity for the data SOURCE (the query/filter behind `entities`), NOT the result. + * `entities` streaming in for the same source keeps this constant; a filter change flips it. The + * worker's ingest is additive (no retract), so a changed key recreates it for a clean re-ingest. + * Omit (e.g. a fixed ego graph) to never recreate -- `entities` is then assumed append-only. + */ + readonly sourceKey?: string; +} + +/** + * A hovered/selected entity's data plus the type maps its card needs. For a freshly-expanded node + * (not in the prop `entities`) this is the expansion it arrived in, since the prop maps don't cover + * it; for a prop entity it is just the prop maps. + */ +interface EntityCardContext { + readonly entity: HashEntity; + readonly rootMap: ClosedMultiEntityTypesRootMap | undefined; + readonly definitions: ClosedMultiEntityTypesDefinitions | undefined; +} + +function toIngestEntities( + entities: HashEntity[], + rootIds: ReadonlySet | undefined, +): IngestEntity[] { + return entities.map((entity) => { + const entityId = entity.metadata.recordId.entityId; + const isLink = entity.linkData !== undefined; + return { + entityId, + entityTypeIds: entity.metadata.entityTypeIds as VersionedUrl[], + isLink, + // A link is never a root. With no root set, every node is a root (no frontier); otherwise + // root-ness is set membership -- non-members render as greyed-out frontier nodes. + isRoot: !isLink && (rootIds === undefined || rootIds.has(entityId)), + linkData: entity.linkData, + // Property values, for NODE entities only, so the worker can name embedding clusters + // by their distinctive shared properties. Links are never embedding-clustered. + properties: isLink ? undefined : entity.properties, + }; + }); +} + +/** + * Best-effort human title from a versioned type URL (".../entity-type//v/N"). + * Used only as a fallback when an ancestor type is absent from `definitions`, so + * a registered parent never ends up with an empty title. + */ +function titleFromUrl(versionedUrl: VersionedUrl): string { + const slug = /\/entity-type\/(?[^/]+)\//.exec(versionedUrl)?.groups + ?.slug; + if (!slug) { + return ""; + } + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * Build one {@link TypeSchemaEntry} per unique VersionedUrl reachable from the + * entities' types — INCLUDING inherited ancestor types that no entity uses + * directly. + * + * Each `entry.allOf` is the inheritance chain of a directly-applied type: the + * type itself at depth 0 followed by its ancestors. Those ancestor entries are + * {@link EntityTypeDisplayMetadata} — they carry `$id`/`depth`/`icon` but NO + * title — so titles for ancestors are looked up in `definitions.entityTypes`. + * + * We register every type in the chain, not just the leaf. Previously only the + * leaf was registered and its ancestors were pushed as bare parent refs; the + * worker then interned a parent URL (e.g. a shared "Company" supertype) but had + * no TypeInfo for it, so root resolution silently produced no root and every + * such child fell into the catch-all "unknown" bucket (rendering as a nameless + * "Other" rollup). Registering ancestors lets the worker resolve a real root + * and group/label them correctly. + */ +function extractTypeSchemas( + entities: HashEntity[], + typeMap: ClosedMultiEntityTypesRootMap | undefined, + definitions: ClosedMultiEntityTypesDefinitions | undefined, +): TypeSchemaEntry[] { + if (!typeMap) { + return []; + } + + const seen = new Map(); + + for (const entity of entities) { + let closedType; + try { + closedType = getClosedMultiEntityTypeFromMap( + typeMap, + entity.metadata.entityTypeIds, + ); + } catch { + continue; + } + + for (const entry of closedType.allOf) { + // Chain ordered shallow-to-deep: index 0 is the type itself, the rest are + // its ancestors (closest first). + const chain = [...entry.allOf].sort((a, b) => a.depth - b.depth); + + for (let depthIdx = 0; depthIdx < chain.length; depthIdx++) { + const node = chain[depthIdx]!; + if (seen.has(node.$id)) { + continue; + } + + // Deeper entries are this type's ancestors. Pointing at all of them + // (not only the direct parent) over-approximates the inheritance DAG + // with transitive edges — harmless for the worker's root resolution + // (root = union of parents' roots) and robust to multiple inheritance + // without reconstructing the DAG here. + const allOfRefs = chain + .slice(depthIdx + 1) + .map((ancestor) => ancestor.$id); + + const definition = definitions?.entityTypes[node.$id]; + + seen.set(node.$id, { + url: node.$id, + // Leaf has its title on `entry`; ancestors come via `definitions`, + // falling back to the URL slug so a parent is never title-less. + title: + definition?.title ?? + (depthIdx === 0 ? entry.title : titleFromUrl(node.$id)), + // Link types carry an inverse (target -> source) title; the leaf has it on `entry`, + // ancestors via `definitions`. Undefined for non-link types (no inverse). + inverseTitle: + definition?.inverse?.title ?? + (depthIdx === 0 ? entry.inverse?.title : undefined), + icon: node.icon ?? definition?.icon, + allOfRefs, + }); + } + } + } + + return [...seen.values()]; +} + +/** + * Property display titles keyed by base URL, for every property type referenced by the + * loaded entities. Shipped to the worker so a distinctive-feature cluster label reads + * "Destination = ..." with the human title rather than a raw base URL. The worker holds + * the property VALUES (on the ingested entities); this supplies their names. + */ +function extractPropertySchemas( + definitions: ClosedMultiEntityTypesDefinitions | undefined, +): PropertySchemaEntry[] { + if (!definitions) { + return []; + } + + const seen = new Map(); + for (const propertyType of Object.values(definitions.propertyTypes)) { + const baseUrl = extractBaseUrl(propertyType.$id); + if (!seen.has(baseUrl)) { + seen.set(baseUrl, { baseUrl, title: propertyType.title }); + } + } + return [...seen.values()]; +} + +export const EntityGraphVisualizerV2 = memo( + ({ + entities, + rootEntityIds, + closedMultiEntityTypesRootMap, + definitions, + loadingComponent, + onEntityClick, + onOpenLinkTable, + config, + sourceKey, + }: EntityGraphVisualizerV2Props) => { + const typeSchemas = useMemo( + () => + extractTypeSchemas( + entities ?? [], + closedMultiEntityTypesRootMap, + definitions, + ), + [entities, closedMultiEntityTypesRootMap, definitions], + ); + + const propertySchemas = useMemo( + () => extractPropertySchemas(definitions), + [definitions], + ); + + const rootIdSet = useMemo( + () => (rootEntityIds ? new Set(rootEntityIds) : undefined), + [rootEntityIds], + ); + + // The data source's identity drives a worker recreate: a changed `sourceKey` (filter change) + // purges the additive worker, since it can't retract the old set. Same key -> `entities` only + // grows, streamed as a tail append below. + const { handle, ready, error } = useGraphWorker({ + config, + typeSchemas, + propertySchemas, + resetKey: sourceKey, + }); + const { shouldShowGuidance, dismissGuidance } = useGraphGuidanceDismissal(); + + const [hover, setHover] = useState<{ + readonly entityId: EntityId; + readonly x: number; + readonly y: number; + } | null>(null); + + const [highwayHover, setHighwayHover] = useState(null); + + // The hovered wholly-frontier cluster, shown as an interactive load card. Unlike the other hover + // cards this one has a button, so it stays open while the cursor is over the bubble OR the card: + // a leave starts a short grace timer that the card's own enter cancels, the standard + // interactive-tooltip handoff. The latest frontier ids ride a ref so the Load handler stays stable. + const [clusterHover, setClusterHover] = useState(null); + const clusterCardHoveredRef = useRef(false); + const clusterCloseTimerRef = useRef | null>( + null, + ); + const clusterFrontierIdsRef = useRef([]); + + // The always-on hub labels overlaid as HTML, re-emitted by the Scene each frame with their + // current on-screen positions so they track the camera + settling layout. + const [entityLabels, setEntityLabels] = useState( + [], + ); + const inFlightFrontierRef = useRef(new Set()); + const [frontierVersion, setFrontierVersion] = useState(0); + const [frontierError, setFrontierError] = useState(); + const [frontierProgress, setFrontierProgress] = useState({ + done: 0, + total: 0, + fetching: false, + }); + + // How many of `entities` have been handed to the worker. `entities` is append-only within one + // data source (see `sourceKey`); a source change recreates the worker, which resets this. + const sentCountRef = useRef(0); + // Frontier nodes the user has already expanded. Tracked locally because the worker learns + // their root-ness via ingest, but the bridge's prop-derived rootIdSet never does. + const expandedRootsRef = useRef(new Set()); + // Freshly-fetched expansion nodes + links (NOT in the prop `entities`) + the type maps their + // card resolves against. The root map is a nested per-type-chain structure, so we keep each + // node's source map rather than deep-merging maps from every expansion. + const expandedByIdRef = useRef(new Map()); + + // The selected entity, pinned with a card (Open button) that tracks its on-screen + // position. The Scene re-emits this through settle + pan/zoom. + const [selection, setSelection] = useState<{ + readonly entityId: EntityId; + readonly x: number; + readonly y: number; + } | null>(null); + + const entityById = useMemo(() => { + const map = new Map(); + for (const entity of entities ?? []) { + map.set(entity.metadata.recordId.entityId, entity); + } + return map; + }, [entities]); + + // Each entity's incident-link count (degree): a link entity carries both endpoints, so one pass + // over the links tallies both sides. Counts the prop links AND the links pulled in by frontier + // expansion (held in `expandedByIdRef`) -- the union the worker itself ingested -- so a hub + // enlarged by expansion reports its true loaded degree, not just its prop-visible links. + // `frontierVersion` bumps after each expansion to recompute. + const degreeById = useMemo(() => { + // The recompute trigger after an expansion mutates the (ref-held) expanded set. + void frontierVersion; + const map = new Map(); + const tally = (entity: HashEntity): void => { + const { linkData } = entity; + if (!linkData) { + return; + } + map.set( + linkData.leftEntityId, + (map.get(linkData.leftEntityId) ?? 0) + 1, + ); + map.set( + linkData.rightEntityId, + (map.get(linkData.rightEntityId) ?? 0) + 1, + ); + }; + for (const entity of entities ?? []) { + tally(entity); + } + for (const context of expandedByIdRef.current.values()) { + tally(context.entity); + } + return map; + }, [entities, frontierVersion]); + + // Resolve a dot's entity to its display label (its name, e.g. "Alice"), the SAME way the hover + // card does -- resolving from the prop set OR a frontier expansion. WHICH dots are hubs (and so + // get an always-on label) is decided by the Scene from the worker's by-degree radius, not here; + // this just names whatever it is asked about. The Scene calls it only when it rebuilds the label + // set (zoom / structure change), never per frame. Undefined on any miss (entity not held, or its + // closed type can't be resolved) so the dot simply goes unlabelled. + const resolveEntityLabel = useCallback( + (entityId: EntityId): string | undefined => { + const propEntity = entityById.get(entityId); + const context = propEntity + ? { entity: propEntity, rootMap: closedMultiEntityTypesRootMap } + : expandedByIdRef.current.get(entityId); + if (!context?.entity || !context.rootMap) { + return undefined; + } + try { + const closedType = getClosedMultiEntityTypeFromMap( + context.rootMap, + context.entity.metadata.entityTypeIds, + ); + return generateEntityLabel(closedType, context.entity); + } catch { + return undefined; + } + }, + [entityById, closedMultiEntityTypesRootMap], + ); + + // Resolve a dot's entity to its TYPE ICON as an atlas key -- the same display field the hover + // card's icon uses (`getDisplayFieldsForClosedEntityType(...).icon`, which walks the type + // hierarchy). The Scene calls this only when it rebuilds the flat-tier icon set (a structure + // change), never per frame. Returns the key only when it's a non-empty STRING (an emoji or an + // image URL); a ReactElement icon (system-type override) or none -> null -> no atlas entry, + // so that dot simply shows no icon. NOT gated on hubs: the IconLayer's soft-LOD sizing hides + // icons on dots that are small on screen, so every entity is eligible. + const resolveEntityIcon = useCallback( + (entityId: EntityId): string | null => { + const entity = entityById.get(entityId); + if (!entity || !closedMultiEntityTypesRootMap) { + return null; + } + try { + const closedType = getClosedMultiEntityTypeFromMap( + closedMultiEntityTypesRootMap, + entity.metadata.entityTypeIds, + ); + const { icon } = getDisplayFieldsForClosedEntityType(closedType); + return typeof icon === "string" && icon.length > 0 ? icon : null; + } catch { + return null; + } + }, + [entityById, closedMultiEntityTypesRootMap], + ); + + // Reset when the worker is torn down (ready goes false→true on first build OR a sourceKey + // recreate): the fresh worker holds nothing, so every local mirror of its state clears too. + useEffect(() => { + if (!ready) { + sentCountRef.current = 0; + expandedRootsRef.current.clear(); + expandedByIdRef.current.clear(); + inFlightFrontierRef.current.clear(); + setFrontierVersion((version) => version + 1); + setFrontierError(undefined); + setFrontierProgress({ done: 0, total: 0, fetching: false }); + } + }, [ready]); + + useEffect(() => { + if (!handle || !ready || !entities?.length || typeSchemas.length === 0) { + return; + } + + // `entities` only grows within one source (a source change recreates the worker and resets + // the count), so stream just the new tail. + const alreadySent = sentCountRef.current; + if (alreadySent >= entities.length) { + return; + } + + const delta = entities.slice(alreadySent); + sentCountRef.current = entities.length; + handle.ingestBatch(toIngestEntities(delta, rootIdSet)); + }, [handle, ready, entities, typeSchemas, rootIdSet]); + + // Clicking a frontier node (fetched, non-root) expands its neighbourhood: fetch it and hand it + // to the worker, whose additive ingest is the merge -- the clicked node flips to a root and + // un-greys; its endpoints become the next frontier. Each id expands at most once. + const expandFrontier = useCallback( + async (entityIds: readonly EntityId[]) => { + if (!handle) { + return; + } + const fresh = freshFrontierIds( + entityIds, + expandedRootsRef.current, + inFlightFrontierRef.current, + ); + if (fresh.length === 0) { + return; + } + + for (const entityId of fresh) { + inFlightFrontierRef.current.add(entityId); + } + setFrontierError(undefined); + setFrontierProgress({ done: 0, total: fresh.length, fetching: true }); + + let done = 0; + try { + for (const batch of frontierExpansionBatches(fresh)) { + const expansion = await fetchFrontierExpansion(batch); + if (!expansion) { + throw new Error("Frontier expansion returned no data."); + } + handle.registerTypes( + extractTypeSchemas( + expansion.entities, + expansion.closedMultiEntityTypes, + expansion.definitions, + ), + extractPropertySchemas(expansion.definitions), + ); + handle.ingestBatch( + toIngestEntities(expansion.entities, new Set(batch)), + ); + for (const entityId of batch) { + expandedRootsRef.current.add(entityId); + } + // Keep the fetched entities + the maps their card resolves against, for hover/selection + // on nodes this expansion revealed (they are not in the prop `entities`). + for (const entity of expansion.entities) { + expandedByIdRef.current.set(entity.metadata.recordId.entityId, { + entity, + rootMap: expansion.closedMultiEntityTypes, + definitions: expansion.definitions, + }); + } + done += batch.length; + setFrontierProgress({ + done, + total: fresh.length, + fetching: done < fresh.length, + }); + } + } catch (fetchError) { + setFrontierError( + fetchError instanceof Error + ? fetchError.message + : "Could not fetch the frontier.", + ); + } finally { + for (const entityId of fresh) { + inFlightFrontierRef.current.delete(entityId); + } + setFrontierProgress((progress) => ({ + ...progress, + fetching: false, + })); + setFrontierVersion((version) => version + 1); + } + }, + [handle], + ); + + const frontierEntityIds = (() => { + void frontierVersion; + if (!rootIdSet) { + return []; + } + const frontier = new Set(); + const addIfFrontier = (entity: HashEntity) => { + const entityId = entity.metadata.recordId.entityId; + if ( + !entity.linkData && + !rootIdSet.has(entityId) && + !expandedRootsRef.current.has(entityId) && + !inFlightFrontierRef.current.has(entityId) + ) { + frontier.add(entityId); + } + }; + for (const entity of entities ?? []) { + addIfFrontier(entity); + } + for (const context of expandedByIdRef.current.values()) { + addIfFrontier(context.entity); + } + return [...frontier]; + })(); + + const fetchCompleteFrontier = useCallback(() => { + void expandFrontier(frontierEntityIds); + }, [expandFrontier, frontierEntityIds]); + + const handleClusterHover = useCallback((next: ClusterHover | null) => { + if (next) { + if (clusterCloseTimerRef.current !== null) { + clearTimeout(clusterCloseTimerRef.current); + clusterCloseTimerRef.current = null; + } + clusterFrontierIdsRef.current = next.frontierEntityIds; + setClusterHover(next); + return; + } + // The cursor left the bubble; keep the card briefly so it can reach the button. The card's + // own onMouseEnter cancels this; its onMouseLeave closes immediately. + if ( + clusterCardHoveredRef.current || + clusterCloseTimerRef.current !== null + ) { + return; + } + clusterCloseTimerRef.current = setTimeout(() => { + clusterCloseTimerRef.current = null; + if (!clusterCardHoveredRef.current) { + setClusterHover(null); + } + }, 140); + }, []); + + const handleClusterCardEnter = useCallback(() => { + clusterCardHoveredRef.current = true; + if (clusterCloseTimerRef.current !== null) { + clearTimeout(clusterCloseTimerRef.current); + clusterCloseTimerRef.current = null; + } + }, []); + + const handleClusterCardLeave = useCallback(() => { + clusterCardHoveredRef.current = false; + setClusterHover(null); + }, []); + + // Load a wholly-frontier cluster: expand every frontier entity it holds (read from the ref so + // this stays stable across the per-frame card re-position). expandFrontier dedupes, so a second + // Load of an in-flight cluster is a no-op. Dismiss the card; the loaded bubble un-greys. + const handleLoadCluster = useCallback(() => { + void expandFrontier(clusterFrontierIdsRef.current); + clusterCardHoveredRef.current = false; + setClusterHover(null); + }, [expandFrontier]); + + useEffect( + () => () => { + if (clusterCloseTimerRef.current !== null) { + clearTimeout(clusterCloseTimerRef.current); + } + }, + [], + ); + + const handleEntitySelect = useCallback( + (next: EntitySelection | null) => { + setSelection(next); + // A frontier node also expands its neighbourhood -- once (see expandedRootsRef). + if ( + next && + rootIdSet && + !rootIdSet.has(next.entityId) && + !expandedRootsRef.current.has(next.entityId) + ) { + void expandFrontier([next.entityId]); + } + }, + [rootIdSet, expandFrontier], + ); + + // Stable Open handler: the selection card's screen position updates every pan frame, so its + // content + callbacks must stay referentially stable or the memoized body re-renders too. + const selectedEntityId = selection?.entityId; + const handleOpenSelected = useCallback(() => { + if (selectedEntityId !== undefined) { + onEntityClick?.(selectedEntityId); + } + }, [onEntityClick, selectedEntityId]); + + if (error) { + return ( + + + + ); + } + + if (!handle) { + return ( + + {loadingComponent} + + + ); + } + + if (ready && (entities?.length ?? 0) === 0) { + return ( + + + + ); + } + + // The card data for an entity: the prop maps for an entity in `entities`, else the expansion + // it arrived in (a node a frontier expand revealed is not in the prop `entities`). + const cardContext = (entityId: EntityId): EntityCardContext | undefined => { + const propEntity = entityById.get(entityId); + if (propEntity) { + return { + entity: propEntity, + rootMap: closedMultiEntityTypesRootMap, + definitions, + }; + } + return expandedByIdRef.current.get(entityId); + }; + + // While a node is pinned (selected), suppress its transient hover card so they don't + // stack on the same dot. + const hoveredCtx = + hover !== null && hover.entityId !== selection?.entityId + ? cardContext(hover.entityId) + : undefined; + const selectedCtx = + selection !== null ? cardContext(selection.entityId) : undefined; + + return ( + + + } + onEntityHover={setHover} + onHighwayHover={setHighwayHover} + onEntitySelect={handleEntitySelect} + onClusterHover={handleClusterHover} + onOpenLinkTable={onOpenLinkTable} + resolveEntityLabel={resolveEntityLabel} + resolveEntityIcon={resolveEntityIcon} + onEntityLabels={setEntityLabels} + /> + + {shouldShowGuidance ? ( + + ) : ( + + )} + + {hover !== null && hoveredCtx !== undefined ? ( + + ) : null} + {highwayHover !== null ? ( + + ) : null} + {clusterHover !== null ? ( + + ) : null} + {selection !== null && selectedCtx !== undefined ? ( + + ) : null} + + ); + }, +); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/fetch-frontier-expansion.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/fetch-frontier-expansion.ts new file mode 100644 index 00000000000..e25b797acac --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/fetch-frontier-expansion.ts @@ -0,0 +1,97 @@ +import { getLatestEntityVertices } from "@blockprotocol/graph/stdlib"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { + currentTimeInstantTemporalAxes, + generateEntityIdFilter, +} from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntitySubgraphQuery } from "../../../graphql/queries/knowledge/entity.queries"; +import { apolloClient } from "../../../lib/apollo-client"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../graphql/api-types.gen"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { TraversalPath } from "@local/hash-graph-client"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; + +/** + * Resolve the links into and out of the expanded nodes so their endpoints come back too and + * become the next frontier (left/right entity edges, both directions). + */ +const frontierTraversalPaths: TraversalPath[] = [ + { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, + { + edges: [ + { kind: "has-right-entity", direction: "incoming" }, + { kind: "has-left-entity", direction: "outgoing" }, + ], + }, +]; + +/** + * The neighbourhood of an expanded frontier set: the fetched entities plus the type data the + * caller needs to register them. The entities' ids are the new roots; the rest of the + * neighbourhood is the next frontier. + */ +export interface FrontierExpansion { + entities: HashEntity[]; + closedMultiEntityTypes: QueryEntitySubgraphQuery["queryEntitySubgraph"]["closedMultiEntityTypes"]; + definitions: QueryEntitySubgraphQuery["queryEntitySubgraph"]["definitions"]; +} + +/** + * Imperatively fetch the neighbourhood of a set of frontier nodes, to feed the worker's additive + * ingest (the whole point of incremental loading). Deliberately a plain async function, NOT a + * reactive hook: it has no React-state dependencies, and a reactive query here would fight the + * additive model. Each id is rooted via {@link generateEntityIdFilter}, so every expanded node + * brings its links + endpoints. + */ +export async function fetchFrontierExpansion( + entityIds: readonly EntityId[], +): Promise { + if (entityIds.length === 0) { + return undefined; + } + + const { data: expansion } = await apolloClient.query< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >({ + query: queryEntitySubgraphQuery, + fetchPolicy: "network-only", + variables: { + request: { + filter: { + any: entityIds.map((entityId) => + generateEntityIdFilter({ entityId, includeArchived: false }), + ), + }, + traversalPaths: frontierTraversalPaths, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }, + }); + + const expandedSubgraph = deserializeQueryEntitySubgraphResponse( + expansion.queryEntitySubgraph, + ).subgraph; + + return { + entities: getLatestEntityVertices(expandedSubgraph).map( + (vertex) => vertex.inner, + ), + closedMultiEntityTypes: + expansion.queryEntitySubgraph.closedMultiEntityTypes, + definitions: expansion.queryEntitySubgraph.definitions, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts new file mode 100644 index 00000000000..fcb82f93008 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts @@ -0,0 +1,277 @@ +import type { ClusterId, VizMode } from "./ids"; +/** + * Types the rendering layer owns: the payloads the worker sends to the main + * thread for Deck.gl to consume. + * + * The data flow is split by update rate, so that the expensive O(entities) and + * O(links) work happens only when topology changes, never on a position tick: + * + * - {@link StructureFrame}: identities, colors, labels, radii, and edge + * topology. Sent ONLY when the visible cut (LOD) changes. Held in a ref on + * the main thread; a version counter drives Deck.gl `updateTriggers`. + * + * - {@link PositionsFrame}: world positions for the (bounded) set of visible + * clusters plus freshly-computed highway/feeder Bezier control points. Sent + * on every force-layout tick while the macro layout is unsettled, then it + * stops (positions are frozen between cut changes). Cluster geometry is tiny + * (bounded by the render budget), so it travels by `postMessage`. + * + * - Entity positions: millions-scale, so they never travel by message. They + * live in a `SharedArrayBuffer` per open leaf (see `LayoutCreatedMessage`) + * and are read directly by the GPU. Entity-incident edges are composed on + * the main thread from that same SAB, so dots and their edges cannot tear. + */ +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; + +export type Color = readonly [ + red: number, + green: number, + blue: number, + alpha: number, +]; + +/** + * A cluster bubble: identity and style. Its world position is delivered + * separately in the index-aligned {@link PositionsFrame.clusterPositions}, so + * a bubble can move (force layout settling) without resending its identity. + */ +export interface RenderCluster { + readonly id: ClusterId; + readonly color: Color; + readonly label: string; + readonly count: number; + /** World-space radius (from stable packing; constant between cut changes). */ + readonly radius: number; + /** + * Nesting depth among open containers. 0 = a leaf/standalone bubble; >0 = an + * opened container, rendered as a faint outline with its label near the top. + */ + readonly depth: number; + /** + * How many of this cluster's members are frontier (fetched-but-unexpanded) + * entities; equals {@link count} when every member is frontier. + */ + readonly frontierCount: number; + /** + * The frontier members' EntityIds, set only when every member is frontier + * ({@link frontierCount} === {@link count}); absent otherwise. + */ + readonly frontierEntityIds?: readonly EntityId[]; +} + +/** + * Describes the individual-entity edges for one open entity-mode leaf. The + * geometry is composed on the main thread from the leaf's position SAB, so it + * tracks the dots exactly. Endpoints are LOCAL to the leaf's center; the main + * thread adds the leaf's world position (see {@link leafClusterIndex}). + */ +export interface RenderEntityLayer { + /** The leaf cluster id; matches a `LayoutCreatedMessage.clusterId` SAB. */ + readonly layoutId: ClusterId; + /** + * Index of this leaf within {@link StructureFrame.clusters} (and therefore + * within {@link PositionsFrame.clusterPositions}). Used to resolve the + * leaf's world center, which is the origin for its entities' local coords. + */ + readonly leafClusterIndex: number; + readonly count: number; + /** Uniform entity-dot radius in world units. */ + readonly radius: number; + readonly color: Color; + /** + * Entity-to-entity internal links: interleaved local index pairs + * `[a0, b0, a1, b1, ...]` into the leaf's position SAB. This is TOPOLOGY (which + * dots link), unchanged between cut changes; the positions come from the SAB. + */ + readonly internalEdges: Uint32Array; + readonly fanOutColor: Color; +} + +/** + * Per open entity-mode leaf, the fan-out feeder endpoints for the CURRENT + * positions: interleaved `[localIdx, exitLocalX, exitLocalY, ...]`, the exit in + * the leaf's LOCAL frame. This is POSITIONAL — the exit moves as the leaf's + * ports re-slot while the macro layout settles — so it rides the + * {@link PositionsFrame}, NOT the (topology-only) {@link StructureFrame}. The + * main thread pairs it with the leaf's {@link RenderEntityLayer} (for + * `leafClusterIndex` + `fanOutColor`) by `layoutId`. + */ +export interface RenderEntityFanOut { + readonly layoutId: ClusterId; + readonly fanOut: Float32Array; +} + +/** + * The whole-graph individual-entity view, used by the `flat-force` and + * `community-force` modes (one regime — see `LAYOUT-MODES.md`). Unlike the + * hierarchical {@link RenderEntityLayer} (one open leaf, uniform colour/radius), + * this is the ENTIRE entity set as one graph, each entity coloured by its type + * (hierarchy-aware) and sized by its degree. + * + * ALL per-node GPU data — positions, radii, colours — lives in one SAB (a + * `FlatGraphBuffer`, delivered via `LayoutCreatedMessage` keyed by {@link + * layoutId}), so the renderer reads it directly and per-node updates are written + * in place. Positions are world coords centred on the origin (no leaf offset). + * This payload therefore carries NO per-node arrays — only the identity + count. + * Edges are worker-built bezier segments ({@link PositionsFrame.beziers}, one per + * link, coloured by the link's own type), drawn separately. + */ +export interface RenderFlatGraph { + /** Matches a {@link "../worker/protocol".LayoutCreatedMessage} clusterId SAB. */ + readonly layoutId: ClusterId; + /** Live node count (≤ the SAB capacity); how many instances to render. */ + readonly count: number; + /** + * Per-node Louvain community id, in SAB record order (`-1` = unassigned). Present + * only in `community-force` (the `CommunityLayout` exposes it). The BubbleSets + * layer groups nodes by this and shades each community; the positions it shades + * come live from the SAB. Changes only on a Louvain (re)run, so it rides the + * (rare) structure frame rather than streaming. + */ + readonly communities?: Int32Array; +} + +/** + * A small summary of one rendered highway lane (an aggregated cluster-to-cluster + * bezier), carried in {@link StructureFrame.highwayLanes} and indexed by the + * lane's `laneId` (its index in the worker's visual-edge list, which is also the + * `id` carried on the lane's bezier segments). A clicked highway segment reads + * its `id`, looks up this summary, and can ask the worker for the full link set. + * + * The array is dense over every visual edge (aggregate AND individual), so the + * index lines up with `laneId`; an individual (non-aggregate) edge gets the + * placeholder `{ typeId: null, typeLabel: "", count: 0, direction: "both" }`. + */ +export interface HighwayLaneSummary { + /** + * The lane's single link type as a VersionedUrl (a lane is single-type by definition), or + * `null` for a multi-type rollup (the `> maxParallelEdgeTypes` collapse). The main thread + * resolves the type's icon + title from the closed type schema it already holds. + */ + readonly typeId: VersionedUrl | null; + readonly typeLabel: string; + readonly count: number; + readonly direction: "forward" | "reverse" | "both"; +} + +export interface StructureFrame { + readonly version: number; + readonly mode: VizMode; + /** + * Visible clusters in a STABLE order. {@link PositionsFrame.clusterPositions} + * is index-aligned with this array until the next structure frame. Empty in + * the flat tiers (see {@link flatGraph}). + */ + readonly clusters: readonly RenderCluster[]; + readonly entityLayers: readonly RenderEntityLayer[]; + /** + * Present in `flat-force` / `community-force`: the whole entity set as one + * individual-entity graph. Mutually exclusive with {@link clusters} / + * {@link entityLayers} (which are empty then). Undefined in `hierarchical-lod`. + */ + readonly flatGraph?: RenderFlatGraph; + /** + * Per-lane summaries for the rendered highways, indexed by `laneId` (the + * lane's index in the worker's visual-edge list). Individual (non-aggregate) + * visual edges occupy their slot with a placeholder so the index aligns. + */ + readonly highwayLanes: readonly HighwayLaneSummary[]; +} + +/** + * Packed edge segments. Hierarchical highways/feeders feed {@link "./render/gpu/bezier-sdf-layer"} + * as cubic Beziers; flat-tier lanes use the same packed p0/p3 endpoints with Deck's `LineLayer`. + * The buffers are parallel: index `i` addresses one segment across all of them. Backing + * `ArrayBuffer`s are transferred (not copied), so a frame carrying them may be consumed only once. + */ +export interface RenderBezierBuffers { + /** + * 8 floats per segment, interleaved: + * `p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y` (four `vec2`s, stride 32 bytes). + */ + readonly positions: Float32Array; + /** 4 bytes per segment: `r, g, b, a` (unsigned, 0..255). */ + readonly colors: Uint8Array; + /** 1 float per segment: stroke width in common/world units. */ + readonly widths: Float32Array; + /** + * 6 floats per segment: two clip circles `(cx, cy, signedRadius)` (stride 24 + * bytes), one per end. Each erases the edge on one side of the circle so it + * ends flush on a bubble wall; `signedRadius > 0` erases inside, `< 0` outside, + * `0` = no clip. (See `ClipCircle` in `edge-geometry.ts`.) + */ + readonly clips: Float32Array; + /** + * 1 u32 per segment, identifying what the segment draws so a picked edge can be + * resolved: + * - Flat tier link: the EntityIdx of the link entity. + * - Hierarchical highway/feeder lane: the lane's `laneId` (its index in the + * worker's visual-edge list, also the index into + * {@link StructureFrame.highwayLanes}). The main thread resolves the full set + * of links via a `QUERY_HIGHWAY_LINKS` round-trip. + * - {@link BEZIER_NO_LINK} when the segment has no resolvable identity. + */ + readonly ids: Uint32Array; + readonly segmentCount: number; +} + +/** Sentinel `RenderBezierBuffers.ids` value for a segment with no single link (a highway). */ +export const BEZIER_NO_LINK = 0xffffffff; + +/** + * A highway label at an edge midpoint: how strongly two clusters are connected. + * Position moves with the layout, so it rides the positions frame. The main + * thread culls these by on-screen chord length to avoid clutter. + */ +export interface RenderEdgeLabel { + readonly x: number; + readonly y: number; + readonly text: string; + /** Degrees (kept upright): rotates the label to ride along its lane. */ + readonly angle: number; + /** + * Label size in WORLD/common units, matching the lane it annotates. + */ + readonly size: number; + /** World-space lane length, for screen-space culling. */ + readonly chord: number; +} + +/** + * A directional arrowhead riding a rendered aggregate highway lane. The worker emits this beside + * the Bezier geometry so the main thread does not need to scan edge topology or re-solve routes. + */ +export interface RenderEdgeArrow { + readonly kind: "lane" | "endpoint"; + readonly x: number; + readonly y: number; + /** World-space angle in radians, pointing in the link flow direction. */ + readonly angle: number; + /** World/common size, derived from the lane width. */ + readonly size: number; + readonly color: Color; + /** World-space lane length, for screen-space culling. */ + readonly chord: number; +} + +/** + * High-frequency position update, valid only against the current + * {@link StructureFrame}. Sent on every tick while the macro layout is + * unsettled, then it stops. Cluster geometry is bounded by the render budget, + * so it travels by `postMessage`. + */ +export interface PositionsFrame { + readonly version: number; + /** True once every macro layout has settled; the last frame of a sequence. */ + readonly settled: boolean; + /** World positions, index-aligned with {@link StructureFrame.clusters}. */ + readonly clusterPositions: Float32Array; + /** Freshly-computed highway/feeder geometry for the current positions. */ + readonly beziers: RenderBezierBuffers; + /** Top connections by count, labelled at their midpoints. */ + readonly edgeLabels: readonly RenderEdgeLabel[]; + /** Direction marks for directed aggregate highway lanes. */ + readonly edgeArrows: readonly RenderEdgeArrow[]; + /** Per open entity-mode leaf, fan-out feeder endpoints (positional). */ + readonly entityFanOut: readonly RenderEntityFanOut[]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/geometry.ts new file mode 100644 index 00000000000..ee3cbba652e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/geometry.ts @@ -0,0 +1,115 @@ +/* eslint-disable no-param-reassign */ +/** + * Geometric primitives for layout and hit testing. + * + * Circle is the readonly interface: LOD, rendering, and hit testing + * read positions through it. MutableCircle is the concrete class + * used during layout passes where positions change in place. + */ + +export interface Circle { + readonly x: number; + readonly y: number; + readonly radius: number; +} + +export function screenRadius(circle: Circle, zoom: number): number { + return circle.radius * 2 ** zoom; +} + +/** + * Mutable circle for layout passes. Collision resolution runs + * thousands of iterations and must mutate positions in place. + */ +export class MutableCircle implements Circle { + x: number; + y: number; + radius: number; + + constructor(x?: number, y?: number, radius?: number) { + this.x = x ?? 0; + this.y = y ?? 0; + this.radius = radius ?? 0; + } + + get isOrigin(): boolean { + return this.x === 0 && this.y === 0 && this.radius === 0; + } + + /** + * Push this circle and `other` apart so they no longer overlap. + * Each moves half the overlap distance along the center-to-center axis. + * + * Returns true if the circles were pushed apart. Returns false + * if they don't overlap, or if they're coincident (distance ≈ 0) + * and can't determine a separation direction. The caller must + * handle the coincident case with domain-specific logic. + */ + pushApart(other: MutableCircle, padding: number): boolean { + const dx = other.x - this.x; + const dy = other.y - this.y; + const dist = Math.hypot(dx, dy); + const minDist = this.radius + other.radius + padding; + + if (dist >= minDist || dist <= 0.001) { + return false; + } + + const push = (minDist - dist) / 2; + const nx = dx / dist; + const ny = dy / dist; + this.x -= nx * push; + this.y -= ny * push; + other.x += nx * push; + other.y += ny * push; + + return true; + } +} + +export class Bbox { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; + + constructor(left: number, right: number, top: number, bottom: number) { + this.left = left; + this.right = right; + this.top = top; + this.bottom = bottom; + } + + static fromViewport( + centerX: number, + centerY: number, + width: number, + height: number, + zoom: number, + ): Bbox { + const scale = 2 ** zoom; + const halfW = width / 2 / scale; + const halfH = height / 2 / scale; + return new Bbox( + centerX - halfW, + centerX + halfW, + centerY - halfH, + centerY + halfH, + ); + } + + containsPoint(x: number, y: number): boolean { + return ( + x >= this.left && x <= this.right && y >= this.top && y <= this.bottom + ); + } + + intersectsCircle(circle: Circle): boolean { + return ( + circle.x + circle.radius >= this.left && + circle.x - circle.radius <= this.right && + circle.y + circle.radius >= this.top && + circle.y - circle.radius <= this.bottom + ); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/graph-visualizer.tsx new file mode 100644 index 00000000000..4122b1c508a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/graph-visualizer.tsx @@ -0,0 +1,137 @@ +/** + * Thin React shell: mounts a {@link Scene} over a container and keeps its interaction + * callbacks current. All rendering state and behavior live in Scene; React owns only + * the container and the loading overlay. The entity hover card is owned by the parent bridge. + */ +import { useEffect, useRef, useState } from "react"; + +import { GraphControls } from "./components/graph-controls"; +import { GRAPH_CAMERA_ZOOM_STEP } from "./interactivity/graph-camera-commands"; +import { Scene } from "./render/scene"; + +import type { + ClusterHover, + EntityHover, + EntityLabel, + EntitySelection, + HighwayHover, +} from "./render/scene"; +import type { WorkerHandle } from "./render/worker-connection"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { ReactElement } from "react"; + +interface GraphVisualizerProps { + readonly handle: WorkerHandle; + readonly loadingComponent: ReactElement; + readonly onEntityHover?: (hover: EntityHover | null) => void; + readonly onHighwayHover?: (hover: HighwayHover | null) => void; + readonly onEntitySelect?: (selection: EntitySelection | null) => void; + /** Open the underlying link entities of an aggregated "highway" edge in a table. */ + readonly onOpenLinkTable?: (linkEntityIds: readonly EntityId[]) => void; + /** Report a hovered wholly-frontier cluster bubble (to offer loading its entities), or null on leave. */ + readonly onClusterHover?: (hover: ClusterHover | null) => void; + /** + * Resolve an entity's display label (its name) for the always-on graph labels. The Scene + * calls this only when it rebuilds the label set (zoom / structure change), never per frame. + */ + readonly resolveEntityLabel?: (entityId: EntityId) => string | undefined; + /** + * Resolve an entity's type icon to an atlas key (emoji or image URL), or null for none. The + * Scene calls this only when it rebuilds the flat-tier icon set (structure change), never per frame. + */ + readonly resolveEntityIcon?: (entityId: EntityId) => string | null; + /** Receive the always-on hub labels (with current on-screen positions) to overlay as HTML. */ + readonly onEntityLabels?: (labels: readonly EntityLabel[]) => void; +} + +export const GraphVisualizerV2 = ({ + handle, + loadingComponent, + onEntityHover, + onHighwayHover, + onEntitySelect, + onOpenLinkTable, + onClusterHover, + resolveEntityLabel, + resolveEntityIcon, + onEntityLabels, +}: GraphVisualizerProps) => { + const containerRef = useRef(null); + const sceneRef = useRef(null); + const [hasStructure, setHasStructure] = useState(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return undefined; + } + const scene = new Scene(container, handle, { + onEntityHover, + onHighwayHover, + onEntitySelect, + onOpenLinkTable, + onClusterHover, + resolveEntityLabel, + resolveEntityIcon, + onEntityLabels, + onFirstStructure: () => setHasStructure(true), + }); + sceneRef.current = scene; + return () => { + scene.dispose(); + sceneRef.current = null; + }; + // Only `handle` re-mounts the scene; callbacks are kept current below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handle]); + + // Keep the scene's interaction callbacks current without re-mounting Deck. + useEffect(() => { + sceneRef.current?.setCallbacks({ + onEntityHover, + onHighwayHover, + onEntitySelect, + onOpenLinkTable, + onClusterHover, + resolveEntityLabel, + resolveEntityIcon, + onEntityLabels, + onFirstStructure: () => setHasStructure(true), + }); + }); + + return ( +
+
+ {!hasStructure && ( +
+ {loadingComponent} +
+ )} + sceneRef.current?.zoomBy(GRAPH_CAMERA_ZOOM_STEP)} + onZoomOut={() => sceneRef.current?.zoomBy(-GRAPH_CAMERA_ZOOM_STEP)} + onFitView={() => sceneRef.current?.fitToContent()} + /> +
+ ); +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts new file mode 100644 index 00000000000..c55b6a843a1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts @@ -0,0 +1,50 @@ +import { Branded as make } from "./brand"; + +/** + * Branded primitive types and domain unions specific to the visualization. + * + * Types that already exist in the ecosystem (EntityId, VersionedUrl, LinkData) + * are imported from @blockprotocol/type-system. This file defines only the + * concepts that are unique to the graph visualizer. + */ +import type { Branded } from "./brand"; + +export type EntityIdx = Branded; +export const EntityIdx = make(); + +export type LinkIdx = Branded; +export const LinkIdx = make(); + +export type TypeIdx = Branded; +export const TypeIdx = make(); + +/** Sorted, comma-joined TypeIdx values. Canonical grouping key. */ +export type TypeSetKey = Branded; +export const TypeSetKey = make(); + +export type TypeSetIdx = Branded; +export const TypeSetIdx = make(); + +export type ClusterId = Branded; +export const ClusterId = make(); + +/** Canonical key for a pair of connected clusters (lexicographic order). */ +export type PairKey = Branded; +export const PairKey = make(); + +/** Stable semantic identity for a visual edge (survives LOD transitions). */ +export type VisualEdgeKey = Branded; +export const VisualEdgeKey = make(); + +export type VizMode = "flat-force" | "community-force" | "hierarchical-lod"; + +export type LodMode = "cluster" | "children" | "entities-pending" | "entities"; + +export type ClusterKind = + | "root" + | "family" + | "type-set" + | "other" + | "community" + | "embedding" + | "entity-bucket"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/frontier-expansion.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/frontier-expansion.ts new file mode 100644 index 00000000000..c2a56044786 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/frontier-expansion.ts @@ -0,0 +1,27 @@ +import type { EntityId } from "@blockprotocol/type-system"; + +export const FRONTIER_EXPANSION_BATCH_SIZE = 50; + +export function frontierExpansionBatches( + entityIds: readonly EntityId[], +): EntityId[][] { + const batches: EntityId[][] = []; + for ( + let start = 0; + start < entityIds.length; + start += FRONTIER_EXPANSION_BATCH_SIZE + ) { + batches.push(entityIds.slice(start, start + FRONTIER_EXPANSION_BATCH_SIZE)); + } + return batches; +} + +export function freshFrontierIds( + entityIds: readonly EntityId[], + expanded: ReadonlySet, + inFlight: ReadonlySet, +): EntityId[] { + return entityIds.filter( + (entityId) => !expanded.has(entityId) && !inFlight.has(entityId), + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/graph-camera-commands.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/graph-camera-commands.ts new file mode 100644 index 00000000000..be8af8dda8e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/graph-camera-commands.ts @@ -0,0 +1 @@ +export const GRAPH_CAMERA_ZOOM_STEP = 0.7; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/use-graph-guidance-dismissal.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/use-graph-guidance-dismissal.ts new file mode 100644 index 00000000000..9ee023b483d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/interactivity/use-graph-guidance-dismissal.ts @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useState } from "react"; + +const GRAPH_GUIDANCE_DISMISSED_KEY = + "hash.graph-visualizer-v2.guidance-dismissed"; + +export function useGraphGuidanceDismissal(): { + readonly shouldShowGuidance: boolean; + readonly dismissGuidance: () => void; +} { + const [shouldShowGuidance, setShouldShowGuidance] = useState(false); + + useEffect(() => { + setShouldShowGuidance( + window.localStorage.getItem(GRAPH_GUIDANCE_DISMISSED_KEY) !== "true", + ); + }, []); + + const dismissGuidance = useCallback(() => { + window.localStorage.setItem(GRAPH_GUIDANCE_DISMISSED_KEY, "true"); + setShouldShowGuidance(false); + }, []); + + return { shouldShowGuidance, dismissGuidance }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts new file mode 100644 index 00000000000..1569ecab685 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts @@ -0,0 +1,298 @@ +/** + * The hierarchical-LOD render, split by update rate: + * - cluster bubbles: a persistent `placed` array (rebuilt on structure, positions mutated + * in place each settling frame) drawn by `clusterBubbleLayer` with `updateTriggers`, so a + * tick re-uploads only positions, never radius/colour; + * - per open leaf, its entity-incident edges (straight lines) and entity dots, read + * straight from the leaf SAB so lines and dots never tear. + */ +import { LineLayer, ScatterplotLayer } from "@deck.gl/layers"; + +import { dimColor } from "../dim-color"; +import { graphColors } from "../visual-style"; +import { + leafColorAttribute, + leafNodeX, + leafNodeY, + leafPositionAttribute, +} from "../worker/buffers/position-buffer"; + +import type { PositionsFrame, RenderCluster, StructureFrame } from "../frames"; +import type { ClusterId, EntityIdx } from "../ids"; +import type { ClusterReference } from "./worker-connection"; +import type { Layer } from "@deck.gl/core"; + +/** A cluster bubble with its current world position (mutated in place across frames). */ +export interface PlacedCluster { + readonly cluster: RenderCluster; + /** Index into `structure.clusters` / `positions.clusterPositions`. */ + readonly index: number; + x: number; + y: number; +} + +function containerFillColor( + cluster: RenderCluster, +): [number, number, number, number] { + // A wholly-frontier bubble (every member fetched-but-unexpanded) reads in the frontier + // grey, matching the greyed-out frontier dots; otherwise it carries its own type colour. + const allFrontier = + cluster.count > 0 && cluster.frontierCount === cluster.count; + const [red, green, blue, alpha] = allFrontier + ? graphColors.frontier + : cluster.color; + // Opened containers read as faint halos; leaf bubbles stay solid. + const out = + cluster.depth > 0 ? Math.max(20, Math.round(alpha * 0.22)) : alpha; + return [red, green, blue, out]; +} + +/** Build the bubble set from a structure frame, ordered deepest-container-first so leaf + * bubbles land on top. */ +export function buildPlaced( + structure: StructureFrame, + positions: PositionsFrame, +): PlacedCluster[] { + const clusterPositions = positions.clusterPositions; + const placed = structure.clusters.map((cluster, index) => ({ + cluster, + index, + x: clusterPositions[index * 2] ?? 0, + y: clusterPositions[index * 2 + 1] ?? 0, + })); + placed.sort((lhs, rhs) => rhs.cluster.depth - lhs.cluster.depth); + return placed; +} + +/** Mutate the placed bubbles' positions in place (array identity preserved, so the bubble + * layer's updateTrigger is what re-uploads them, leaving radius/colour untouched). */ +export function updatePlaced( + placed: PlacedCluster[], + positions: PositionsFrame, +): void { + const clusterPositions = positions.clusterPositions; + for (const entry of placed) { + entry.x = clusterPositions[entry.index * 2] ?? 0; + entry.y = clusterPositions[entry.index * 2 + 1] ?? 0; + } +} + +/** Cluster bubbles. `positionTick` drives the getPosition updateTrigger, so a settling + * frame re-uploads only positions; radius regenerates only when `placed` is rebuilt (the + * structure changed), and colour also when `highlightTick` changes (the focus dim). */ +export function clusterBubbleLayer( + placed: PlacedCluster[], + positionTick: number, + /** While a highlight is active, the clusters to keep at full colour (the rest recede); null + * when nothing is selected. `highlightTick` drives the getFillColor updateTrigger. */ + keepFull: ReadonlySet | null, + highlightTick: number, +): Layer { + return new ScatterplotLayer({ + id: "clusters", + data: placed, + getPosition: (datum) => [datum.x, datum.y], + getRadius: (datum) => datum.cluster.radius, + getFillColor: (datum) => { + const base = containerFillColor(datum.cluster); + // A leaf-level bubble (depth 0) holding nothing highlighted recedes. Open containers + // (depth > 0) are faint halos on the path to the selection, so they stay as they are. + if ( + keepFull === null || + datum.cluster.depth > 0 || + keepFull.has(datum.cluster.id) + ) { + return base; + } + return dimColor(base); + }, + radiusUnits: "common", + stroked: true, + getLineColor: graphColors.clusterStroke, + lineWidthUnits: "pixels", + getLineWidth: 1, + pickable: true, + updateTriggers: { + getPosition: positionTick, + getFillColor: highlightTick, + }, + }); +} + +/** Per open leaf: its entity-incident edges (straight) and entity dots, drawn ON TOP of + * the faint container bubble. */ +export function clusterEntityLayers(config: { + readonly structure: StructureFrame; + readonly positions: PositionsFrame; + readonly clusters: Map; + /** Drives the dots' getPosition updateTrigger, so the SAB re-uploads only on a tick. */ + readonly positionTick: number; + /** Highlighted entities (selection + ego); a leaf line whose endpoints aren't all in here + * dims, in step with the dots. Empty = no selection, every line full. */ + readonly highlightedEntities: ReadonlySet; +}): Layer[] { + const { structure, positions, clusters, positionTick, highlightedEntities } = + config; + const dimActive = highlightedEntities.size > 0; + // A leaf-local node is highlighted if its entityIdx (its nodeIds entry) is in the set -- the + // SAME lookup the dots' colours use, so a line dims exactly when its endpoints' dots do. + const nodeHighlighted = (ref: ClusterReference, local: number): boolean => { + const id = ref.nodeIds[local]; + return id !== undefined && highlightedEntities.has(Number(id) as EntityIdx); + }; + const clusterPositions = positions.clusterPositions; + // Fan-out feeder endpoints are POSITIONAL (they ride the positions frame, keyed by leaf + // id), not the structure. Build a quick lookup for the loop. + const fanOutByLeaf = new Map(); + for (const entry of positions.entityFanOut) { + fanOutByLeaf.set(entry.layoutId, entry.fanOut); + } + + const result: Layer[] = []; + for (const layer of structure.entityLayers) { + const cluster = clusters.get(layer.layoutId); + if (!cluster) { + continue; + } + + const originX = clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + + const fanOut = fanOutByLeaf.get(layer.layoutId); + + const fanCount = fanOut ? Math.floor(fanOut.length / 3) : 0; + if (fanOut && fanCount > 0) { + const src = new Float32Array(fanCount * 2); + const dst = new Float32Array(fanCount * 2); + const colors = dimActive ? new Uint8Array(fanCount * 4) : undefined; + + for (let edge = 0; edge < fanCount; edge++) { + const entity = fanOut[edge * 3]!; + src[edge * 2] = leafNodeX(cluster.positions, entity); + src[edge * 2 + 1] = leafNodeY(cluster.positions, entity); + dst[edge * 2] = fanOut[edge * 3 + 1]!; + dst[edge * 2 + 1] = fanOut[edge * 3 + 2]!; + if (colors) { + // A feeder dims with its own dot (the source entity). + colors.set( + nodeHighlighted(cluster, entity) + ? layer.fanOutColor + : dimColor(layer.fanOutColor), + edge * 4, + ); + } + } + + result.push( + new LineLayer({ + id: `fanout:${layer.layoutId}`, + data: { + length: fanCount, + attributes: { + getSourcePosition: { value: src, size: 2 }, + getTargetPosition: { value: dst, size: 2 }, + ...(colors + ? { getColor: { value: colors, size: 4, normalized: true } } + : {}), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + originX, originY, 0, 1, + ], + ...(colors ? {} : { getColor: layer.fanOutColor }), + getWidth: 1, + widthUnits: "pixels", + pickable: false, + }), + ); + } + + const internalCount = Math.floor(layer.internalEdges.length / 2); + if (internalCount > 0) { + const src = new Float32Array(internalCount * 2); + const dst = new Float32Array(internalCount * 2); + const colors = dimActive ? new Uint8Array(internalCount * 4) : undefined; + + for (let edge = 0; edge < internalCount; edge++) { + const left = layer.internalEdges[edge * 2]!; + const right = layer.internalEdges[edge * 2 + 1]!; + + src[edge * 2] = leafNodeX(cluster.positions, left); + src[edge * 2 + 1] = leafNodeY(cluster.positions, left); + dst[edge * 2] = leafNodeX(cluster.positions, right); + dst[edge * 2 + 1] = leafNodeY(cluster.positions, right); + if (colors) { + // Full only if BOTH endpoints are highlighted (a line bridging in/out of the ego + // recedes with the field), matching the flat tier's per-link rule. + const full = + nodeHighlighted(cluster, left) && nodeHighlighted(cluster, right); + colors.set(full ? layer.color : dimColor(layer.color), edge * 4); + } + } + + result.push( + new LineLayer({ + id: `internal:${layer.layoutId}`, + data: { + length: internalCount, + attributes: { + getSourcePosition: { value: src, size: 2 }, + getTargetPosition: { value: dst, size: 2 }, + ...(colors + ? { getColor: { value: colors, size: 4, normalized: true } } + : {}), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + originX, originY, 0, 1, + ], + ...(colors ? {} : { getColor: layer.color }), + getWidth: 1, + opacity: 0.5, + widthUnits: "pixels", + pickable: false, + }), + ); + } + + // Entity dots read straight from the interleaved leaf SAB (no per-frame gather): position + // and per-node colour are binary attributes over the same buffer, the leaf origin is a + // modelMatrix uniform, and the positionTick updateTrigger re-uploads on a tick / recolour. + result.push( + new ScatterplotLayer({ + id: `entities:${layer.layoutId}`, + data: { + length: layer.count, + attributes: { + getPosition: leafPositionAttribute(cluster.versionView.buffer), + getFillColor: leafColorAttribute(cluster.versionView.buffer), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + originX, originY, 0, 1, + ], + getRadius: layer.radius, + radiusUnits: "common", + pickable: true, + updateTriggers: { + getPosition: positionTick, + getFillColor: positionTick, + }, + }), + ); + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts new file mode 100644 index 00000000000..59769bff257 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts @@ -0,0 +1,148 @@ +import { communityColorForId } from "../visual-style"; +import { + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, +} from "../worker/buffers/position-buffer"; +/** + * community-force "BubbleSets": one crisp metaball isocontour per Louvain community, + * drawn behind the dots/edges. Pure layer builder — gathers each kept community's node + * centres (from the same SAB the dots read) into the {@link BubbleSetSDFLayer}'s positions + * texture; the shader sums + thresholds the field. + * + * PERF TODO (only if this shows up in a profile): this re-gathers the grouped positions + * texture and recomputes the bbox every frame (O(nodes)). The win is a STABLE per-community + * index list ([offset, count] into an index buffer of SAB node indices), rebuilt only when + * communities change (Louvain rerun), with the SDF shader reading SAB positions directly via + * that index (stride-aware, since the SAB is interleaved); keep the bbox CPU from the index + * lists (cheap O(nodes) min/max) or move it to a GPU reduction. NOTE: the naive "per-node + * membership + -1, scan all nodes per pixel" is a REGRESSION (O(N)/pixel) — the index list is + * the win. Last resort: move the grouping + bbox into the worker and ride the frame. + */ +import { BubbleSetSDFLayer } from "./gpu/bubble-set-sdf-layer"; + +import type { RenderFlatGraph } from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./worker-connection"; +import type { Layer } from "@deck.gl/core"; + +/** Metaball field radius (world units): wide enough that a community's + * neighbouring nodes' fields merge into one blob. Tune visually. */ +const FLAT_BUBBLE_FIELD_RADIUS = 50; +/** Only PROMOTE non-trivial communities — a pair/singleton needs no hull, and + * bubbling every one (most of a mostly-disconnected graph) is what turns the + * canvas to mud. Tunable. */ +const MIN_COMMUNITY_SIZE = 4; +/** Width of the positions texture the metaball shader samples (rows wrap). */ +const BUBBLE_TEX_WIDTH = 256; + +/** + * Community "BubbleSets" for community-force: ONE crisp metaball isocontour per + * Louvain community, coloured by community, drawn BEHIND the dots/edges. Builds + * the per-community instances (bbox + colour + node range) and a positions texture + * of the kept communities' node centres (gathered from the SAME SAB as the dots); + * the layer's shader sums + thresholds the field. Only non-trivial communities are + * promoted ({@link MIN_COMMUNITY_SIZE}). Absent in flat-force (no `communities`). + */ +export function communityLayer( + graph: RenderFlatGraph, + clusters: Map, +): Layer[] { + const membership = graph.communities; + if (!membership) { + return []; + } + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + const floats = new Float32Array(cluster.versionView.buffer); + const headerFloats = FLAT_HEADER_BYTES / 4; + const recordFloats = FLAT_RECORD_BYTES / 4; + + // Group node indices by community; keep only the non-trivial ones. + const byCommunity = new Map(); + for (let idx = 0; idx < graph.count; idx++) { + const community = membership[idx] ?? -1; + if (community < 0) { + continue; + } + const members = byCommunity.get(community); + if (members) { + members.push(idx); + } else { + byCommunity.set(community, [idx]); + } + } + const kept = [...byCommunity.entries()].filter( + ([, members]) => members.length >= MIN_COMMUNITY_SIZE, + ); + if (kept.length === 0) { + return []; + } + + // Per-community node centres → one grouped positions texture; per-community + // instances (bbox padded by the field radius so the kernel falloff fits, colour, + // and the [offset, count] range into the texture). + const totalNodes = kept.reduce((sum, [, members]) => sum + members.length, 0); + const texHeight = Math.max(1, Math.ceil(totalNodes / BUBBLE_TEX_WIDTH)); + const positions = new Float32Array(BUBBLE_TEX_WIDTH * texHeight * 2); + const bounds = new Float32Array(kept.length * 4); + const colors = new Uint8Array(kept.length * 4); + const ranges = new Float32Array(kept.length * 2); + + let offset = 0; + for (let ci = 0; ci < kept.length; ci++) { + const [community, members] = kept[ci]!; + const start = offset; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const idx of members) { + const posX = floats[headerFloats + idx * recordFloats] ?? 0; + const posY = floats[headerFloats + idx * recordFloats + 1] ?? 0; + positions[offset * 2] = posX; + positions[offset * 2 + 1] = posY; + minX = Math.min(minX, posX); + maxX = Math.max(maxX, posX); + minY = Math.min(minY, posY); + maxY = Math.max(maxY, posY); + offset += 1; + } + bounds[ci * 4] = minX - FLAT_BUBBLE_FIELD_RADIUS; + bounds[ci * 4 + 1] = minY - FLAT_BUBBLE_FIELD_RADIUS; + bounds[ci * 4 + 2] = maxX + FLAT_BUBBLE_FIELD_RADIUS; + bounds[ci * 4 + 3] = maxY + FLAT_BUBBLE_FIELD_RADIUS; + const [red, green, blue, alpha] = communityColorForId(community); + colors[ci * 4] = red; + colors[ci * 4 + 1] = green; + colors[ci * 4 + 2] = blue; + colors[ci * 4 + 3] = alpha; + ranges[ci * 2] = start; + ranges[ci * 2 + 1] = members.length; + } + + return [ + new BubbleSetSDFLayer({ + id: "flat-bubbles", + data: { + length: kept.length, + attributes: { + getBounds: { value: bounds, size: 4 }, + getColor: { value: colors, size: 4 }, + getNodeRange: { value: ranges, size: 2 }, + }, + }, + positions, + texWidth: BUBBLE_TEX_WIDTH, + texHeight, + fieldRadius: FLAT_BUBBLE_FIELD_RADIUS, + isoThreshold: 0.58, + // A backdrop must not write depth, or its bounding quad stamps the depth buffer and the + // coplanar edges drawn AFTER it (the flat tier draws bubbles first) fail the depth test and + // vanish. Set at the layer level so deck's render pass honours it -- the Model parameter + // alone is overridden by the pass defaults. + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts new file mode 100644 index 00000000000..7dfb045f3a8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts @@ -0,0 +1,119 @@ +import { TextLayer } from "@deck.gl/layers"; + +import { EndpointVLayer } from "./gpu/endpoint-v-layer"; + +import type { PositionsFrame, RenderEdgeArrow } from "../frames"; +import type { Layer } from "@deck.gl/core"; + +const EDGE_ARROW_MIN_SCREEN_CHORD = 36; +const EDGE_ARROW_FADE_PX = 14; +const EDGE_ARROW_TEXT = "›"; + +interface EdgeArrowSplit { + readonly lanes: RenderEdgeArrow[]; + readonly endpoints: RenderEdgeArrow[]; +} + +const edgeArrowSplitCache = new WeakMap< + readonly RenderEdgeArrow[], + EdgeArrowSplit +>(); + +function fadeAlpha(screenMetric: number, threshold: number, alpha: number) { + const progress = Math.min( + 1, + Math.max( + 0, + (screenMetric - threshold + EDGE_ARROW_FADE_PX) / EDGE_ARROW_FADE_PX, + ), + ); + return Math.round(alpha * progress); +} + +function arrowAngleDegrees(arrow: RenderEdgeArrow): number { + // TextLayer angles are screen-space degrees; the graph's y axis is projected through + // OrthographicView, so mirror the worker's world-space radians as labels do. + return (-arrow.angle * 180) / Math.PI; +} + +function splitEdgeArrows(arrows: readonly RenderEdgeArrow[]): EdgeArrowSplit { + const cached = edgeArrowSplitCache.get(arrows); + if (cached) { + return cached; + } + + const lanes: RenderEdgeArrow[] = []; + const endpoints: RenderEdgeArrow[] = []; + for (const arrow of arrows) { + if (arrow.kind === "lane") { + lanes.push(arrow); + } else { + endpoints.push(arrow); + } + } + + const split = { lanes, endpoints }; + edgeArrowSplitCache.set(arrows, split); + return split; +} + +export function edgeArrowLayer( + positions: PositionsFrame, + zoom: number, +): Layer[] { + if (positions.edgeArrows.length === 0) { + return []; + } + + const scale = 2 ** zoom; + const { lanes, endpoints } = splitEdgeArrows(positions.edgeArrows); + return [ + ...(endpoints.length > 0 + ? [ + new EndpointVLayer({ + id: "edge-endpoint-arrows", + data: endpoints, + getSize: (arrow) => arrow.size, + getColor: (arrow) => [ + arrow.color[0], + arrow.color[1], + arrow.color[2], + fadeAlpha( + arrow.chord * scale, + EDGE_ARROW_MIN_SCREEN_CHORD, + Math.min(255, Math.round(arrow.color[3] * 1.28)), + ), + ], + pickable: false, + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ] + : []), + ...(lanes.length > 0 + ? [ + new TextLayer({ + id: "edge-lane-chevrons", + data: lanes, + getPosition: (arrow) => [arrow.x, arrow.y], + getText: () => EDGE_ARROW_TEXT, + getSize: (arrow) => arrow.size, + sizeUnits: "common", + getAngle: arrowAngleDegrees, + getColor: (arrow) => [ + 255, + 255, + 255, + fadeAlpha(arrow.chord * scale, EDGE_ARROW_MIN_SCREEN_CHORD, 230), + ], + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontFamily: "Inter, sans-serif", + fontWeight: 700, + characterSet: [EDGE_ARROW_TEXT], + pickable: false, + parameters: { depthWriteEnabled: false, depthCompare: "always" }, + }), + ] + : []), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.test.ts new file mode 100644 index 00000000000..c945f1d524d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.test.ts @@ -0,0 +1,53 @@ +import { LineLayer } from "@deck.gl/layers"; +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { BEZIER_NO_LINK } from "../frames"; +import { edgeLayer } from "./edges"; +import { BezierSDFLayer } from "./gpu/bezier-sdf-layer"; + +import type { PositionsFrame, RenderBezierBuffers } from "../frames"; + +function beziers(): RenderBezierBuffers { + return { + positions: new Float32Array([0, 0, 10, 0, 20, 0, 30, 0]), + colors: new Uint8Array([180, 80, 80, 200]), + widths: new Float32Array([1.2]), + clips: new Float32Array(6), + ids: new Uint32Array([BEZIER_NO_LINK]), + segmentCount: 1, + }; +} + +function positionsFrame(): PositionsFrame { + return { + version: 1, + settled: true, + clusterPositions: new Float32Array(), + beziers: beziers(), + edgeLabels: [], + edgeArrows: [], + entityFanOut: [], + }; +} + +describe("edgeLayer", () => { + it("uses a distinct LineLayer id for flat edges", () => { + const layers = edgeLayer(positionsFrame(), true); + + expect(layers).toHaveLength(1); + expect(layers[0]?.id).toBe("flat-edges"); + expect(layers[0]).toBeInstanceOf(LineLayer); + }); + + it("uses distinct Bezier layer ids for hierarchical edges", () => { + const layers = edgeLayer(positionsFrame(), false); + + expect(layers.map((layer) => layer.id)).toEqual([ + "edges-underlay", + "hierarchical-edges", + ]); + expect(layers[0]).toBeInstanceOf(BezierSDFLayer); + expect(layers[1]).toBeInstanceOf(BezierSDFLayer); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts new file mode 100644 index 00000000000..53bec0df29d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts @@ -0,0 +1,121 @@ +/** + * Hierarchical edges render as GPU SDF beziers. Flat edges reuse the same packed segment buffer, + * but render as plain LineLayer segments because their control points are collinear. + */ +import { LineLayer } from "@deck.gl/layers"; + +import { graphColors } from "../visual-style"; +import { BezierSDFLayer } from "./gpu/bezier-sdf-layer"; + +import type { PositionsFrame, RenderBezierBuffers } from "../frames"; +import type { Layer } from "@deck.gl/core"; + +const edgeUnderlayColors = new WeakMap(); + +function underlayColorAttribute(beziers: RenderBezierBuffers): Uint8Array { + const cached = edgeUnderlayColors.get(beziers); + if (cached) { + return cached; + } + const colors = new Uint8Array(beziers.segmentCount * 4); + for (let index = 0; index < beziers.segmentCount; index++) { + colors.set(graphColors.edgeUnderlay, index * 4); + } + edgeUnderlayColors.set(beziers, colors); + return colors; +} + +function bezierData( + beziers: RenderBezierBuffers, + colors: Uint8Array, +): ConstructorParameters[0]["data"] { + return { + length: beziers.segmentCount, + attributes: { + getP0: { value: beziers.positions, size: 2, stride: 32, offset: 0 }, + getP1: { value: beziers.positions, size: 2, stride: 32, offset: 8 }, + getP2: { value: beziers.positions, size: 2, stride: 32, offset: 16 }, + getP3: { value: beziers.positions, size: 2, stride: 32, offset: 24 }, + getColor: { value: colors, size: 4 }, + getWidth: { value: beziers.widths, size: 1 }, + getClipA: { value: beziers.clips, size: 3, stride: 24, offset: 0 }, + getClipB: { value: beziers.clips, size: 3, stride: 24, offset: 12 }, + }, + }; +} + +function lineData( + beziers: RenderBezierBuffers, + colors: Uint8Array, +): ConstructorParameters[0]["data"] { + return { + length: beziers.segmentCount, + attributes: { + instanceSourcePositions: { + value: beziers.positions, + size: 2, + stride: 32, + offset: 0, + }, + instanceTargetPositions: { + value: beziers.positions, + size: 2, + stride: 32, + offset: 24, + }, + instanceColors: { value: colors, size: 4, type: "unorm8" }, + instanceWidths: { value: beziers.widths, size: 1 }, + }, + }; +} + +export function edgeLayer(positions: PositionsFrame, isFlat: boolean): Layer[] { + const { beziers } = positions; + if (beziers.segmentCount === 0) { + return []; + } + + const widthScale = 1; + const parameters = { + depthWriteEnabled: false, + depthCompare: "always", + } as const; + + const layers: Layer[] = []; + if (isFlat) { + return [ + new LineLayer({ + id: "flat-edges", + data: lineData(beziers, beziers.colors), + pickable: true, + widthUnits: "common", + widthScale, + parameters, + }), + ]; + } + + layers.push( + new BezierSDFLayer({ + id: "edges-underlay", + data: bezierData(beziers, underlayColorAttribute(beziers)), + pickable: false, + boundsPaddingPixels: 10, + widthUnits: "common", + widthScale: widthScale * 1.65, + parameters, + }), + ); + layers.push( + new BezierSDFLayer({ + id: "hierarchical-edges", + data: bezierData(beziers, beziers.colors), + pickable: true, + boundsPaddingPixels: 8, + widthUnits: "common", + widthScale, + parameters, + }), + ); + return layers; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts new file mode 100644 index 00000000000..4a256008dde --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts @@ -0,0 +1,65 @@ +/** + * The flat-tier (flat-force / community-force) NODE render: every entity as one dot, read + * STRAIGHT off the interleaved SAB (positions + radii + colours in one buffer). Fresh + * typed-array views over the shared bytes each render (zero-copy, new identity so Deck + * re-uploads) with stride/offset onto the record fields; positions are world coords + * centred on the origin, so there is no transform. Edges are drawn by the bezier layer. + */ +import { ScatterplotLayer } from "@deck.gl/layers"; + +import { + FLAT_COLOR_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, +} from "../worker/buffers/position-buffer"; + +import type { RenderFlatGraph } from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./worker-connection"; +import type { Layer } from "@deck.gl/core"; + +export function flatDotsLayer( + graph: RenderFlatGraph, + clusters: Map, +): Layer[] { + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + // Views over the WHOLE buffer; the stride/offset address each record field. + const raw = cluster.versionView.buffer; + const floats = new Float32Array(raw); + const bytes = new Uint8Array(raw); + return [ + new ScatterplotLayer({ + id: "flat-entities", + data: { + length: graph.count, + attributes: { + getPosition: { + value: floats, + size: 2, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES, + }, + getRadius: { + value: floats, + size: 1, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES + FLAT_RADIUS_BYTE_OFFSET, + }, + getFillColor: { + value: bytes, + size: 4, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES + FLAT_COLOR_BYTE_OFFSET, + normalized: true, + }, + }, + }, + radiusUnits: "common", + pickable: true, + }), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bezier-sdf-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bezier-sdf-layer.ts new file mode 100644 index 00000000000..bae5d6fd95d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bezier-sdf-layer.ts @@ -0,0 +1,475 @@ +/** + * Custom Deck.gl layer: renders cubic Bezier curves using SDF + * (Signed Distance Field) evaluation in the fragment shader. + * + * Each instance is a screen-space quad covering the Bezier control + * point bounding box. The fragment shader computes exact distance + * to the cubic Bezier via brute-force search + Newton refinement, + * then applies smoothstep antialiasing. + * + * Result: pixel-perfect smooth curves at any zoom level, no + * tessellation artifacts, no jagged joints. GPU-accelerated. + * + * Data format: each datum represents one cubic Bezier segment + * with control points p0, p1, p2, p3 in world coordinates. + */ +import { Layer, picking, project32, UNIT } from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; + +import type { LayerProps, Unit, UpdateParameters } from "@deck.gl/core"; +import type { ShaderModule } from "@luma.gl/shadertools"; + +export interface BezierSegmentDatum { + readonly p0: readonly [number, number]; + readonly p1: readonly [number, number]; + readonly p2: readonly [number, number]; + readonly p3: readonly [number, number]; + readonly color: readonly [number, number, number, number]; + readonly width: number; + /** Clip circles `(cx, cy, signedRadius)` per end; default no clip. */ + readonly clipA?: readonly [number, number, number]; + readonly clipB?: readonly [number, number, number]; +} + +// Shader module for custom uniforms + +interface BezierUniformProps { + uViewportSize: [number, number]; + uBoundsPaddingPixels: number; + /** Width unit + scale, same mechanism as the core LineLayer. */ + widthUnits: number; + widthScale: number; +} + +const bezierUniforms = { + name: "bezier", + vs: `\ +layout(std140) uniform bezierUniforms { + vec2 uViewportSize; + float uBoundsPaddingPixels; + float widthScale; + highp int widthUnits; +} bezier; +`, + fs: `\ +layout(std140) uniform bezierUniforms { + vec2 uViewportSize; + float uBoundsPaddingPixels; + float widthScale; + highp int widthUnits; +} bezier; +`, + uniformTypes: { + uViewportSize: "vec2", + uBoundsPaddingPixels: "f32", + widthScale: "f32", + widthUnits: "i32", + }, + defaultUniforms: { + uViewportSize: [1, 1] as [number, number], + uBoundsPaddingPixels: 4, + widthScale: 1, + widthUnits: UNIT.pixels, + }, +} satisfies ShaderModule; + +// Vertex shader: project control points to screen space, +// expand a quad covering the bounding box + padding. + +const vs = `\ +#version 300 es +#define SHADER_NAME bezier-sdf-layer-vertex + +in vec2 positions; + +in vec2 instanceP0; +in vec2 instanceP1; +in vec2 instanceP2; +in vec2 instanceP3; +in float instanceWidths; +in vec4 instanceColors; +in vec3 instanceClipA; +in vec3 instanceClipB; +in vec3 instancePickingColors; + +out vec2 vPixel; +out vec2 vP0; +out vec2 vP1; +out vec2 vP2; +out vec2 vP3; +out float vWidth; +out vec4 vColor; +out vec2 vClipACenter; +out float vClipARadiusPx; +out vec2 vClipBCenter; +out float vClipBRadiusPx; + +vec2 graphToPixel(vec2 position) { + // Control points are 2D (z = 0); lift to vec3 for projection. + vec3 projected = project_position(vec3(position, 0.0)); + vec4 clip = project_common_position_to_clipspace(vec4(projected, 1.0)); + vec2 ndc = clip.xy / clip.w; + return (ndc * 0.5 + 0.5) * bezier.uViewportSize; +} + +void main(void) { + vec2 p0 = graphToPixel(instanceP0); + vec2 p1 = graphToPixel(instanceP1); + vec2 p2 = graphToPixel(instanceP2); + vec2 p3 = graphToPixel(instanceP3); + + vec2 minP = min(min(p0, p1), min(p2, p3)); + vec2 maxP = max(max(p0, p1), max(p2, p3)); + + // Stroke width → pixels via Deck's unit conversion (pixels / common / meters). Readability is a + // caller-owned design decision; this shader does not hide it behind min/max clamps. + float widthPx = max( + 0.0, + project_size_to_pixel(instanceWidths * bezier.widthScale, bezier.widthUnits) + ); + + float pad = widthPx * 0.5 + bezier.uBoundsPaddingPixels; + minP -= vec2(pad); + maxP += vec2(pad); + + vec2 uv = positions * 0.5 + 0.5; + vec2 pixel = mix(minP, maxP, uv); + + vPixel = pixel; + vP0 = p0; + vP1 = p1; + vP2 = p2; + vP3 = p3; + vWidth = widthPx; + vColor = instanceColors; + + // Each segment is one pickable instance; deck decodes this back to its index on hover. + picking_setPickingColor(instancePickingColors); + + // Clip circles → pixel space. Project the centre, and a point one world-radius + // away, so the pixel radius tracks the same projection the bubble uses. Keep + // the radius SIGN (which side to erase); a zero radius means "no clip". + vClipACenter = graphToPixel(instanceClipA.xy); + vClipARadiusPx = + distance(vClipACenter, graphToPixel(instanceClipA.xy + vec2(abs(instanceClipA.z), 0.0))) + * sign(instanceClipA.z); + vClipBCenter = graphToPixel(instanceClipB.xy); + vClipBRadiusPx = + distance(vClipBCenter, graphToPixel(instanceClipB.xy + vec2(abs(instanceClipB.z), 0.0))) + * sign(instanceClipB.z); + + vec2 ndc = pixel / bezier.uViewportSize * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); +} +`; + +// Fragment shader: SDF distance to cubic Bezier. +// Brute-force search (24 samples) + Newton refinement (5 iterations). + +const fs = `\ +#version 300 es +#define SHADER_NAME bezier-sdf-layer-fragment +precision highp float; + +in vec2 vPixel; +in vec2 vP0; +in vec2 vP1; +in vec2 vP2; +in vec2 vP3; +in float vWidth; +in vec4 vColor; +in vec2 vClipACenter; +in float vClipARadiusPx; +in vec2 vClipBCenter; +in float vClipBRadiusPx; + +out vec4 fragColor; + +vec2 cubicBezier(vec2 a, vec2 b, vec2 c, vec2 d, float t) { + float u = 1.0 - t; + return u * u * u * a + + 3.0 * u * u * t * b + + 3.0 * u * t * t * c + + t * t * t * d; +} + +vec2 cubicBezierDeriv(vec2 a, vec2 b, vec2 c, vec2 d, float t) { + float u = 1.0 - t; + return 3.0 * u * u * (b - a) + + 6.0 * u * t * (c - b) + + 3.0 * t * t * (d - c); +} + +vec2 cubicBezierDeriv2(vec2 a, vec2 b, vec2 c, vec2 d, float t) { + return 6.0 * (1.0 - t) * (c - 2.0 * b + a) + + 6.0 * t * (d - 2.0 * c + b); +} + +float distToCubicBezier(vec2 p, vec2 a, vec2 b, vec2 c, vec2 d) { + // Coarse search: 24 uniform samples. + float bestT = 0.0; + float bestD2 = 1.0e30; + + for (int i = 0; i <= 24; i++) { + float t = float(i) / 24.0; + vec2 q = cubicBezier(a, b, c, d, t); + float d2 = dot(q - p, q - p); + if (d2 < bestD2) { + bestD2 = d2; + bestT = t; + } + } + + // Newton refinement: 5 iterations. + float t = bestT; + for (int i = 0; i < 5; i++) { + vec2 q = cubicBezier(a, b, c, d, t); + vec2 d1 = cubicBezierDeriv(a, b, c, d, t); + vec2 d2 = cubicBezierDeriv2(a, b, c, d, t); + + vec2 r = q - p; + float num = dot(r, d1); + float den = dot(d1, d1) + dot(r, d2); + + if (abs(den) > 1.0e-5) { + t = clamp(t - num / den, 0.0, 1.0); + } + } + + vec2 q = cubicBezier(a, b, c, d, t); + return length(q - p); +} + +// Clip one side via a circle, which is positioned at the centre with the radius specified. +// A negative signedRadiusPx erases OUTSIDE (keep inside); positive erases INSIDE (keep outside); +// ~0 means no clip. Returns a 0..1 alpha multiplier. +float clipFactor(vec2 pixel, vec2 center, float signedRadiusPx) { + float r = abs(signedRadiusPx); + if (r < 0.001) { + return 1.0; + } + float caa = 1.0; + float coverage = smoothstep(r - caa, r + caa, distance(pixel, center)); + return signedRadiusPx > 0.0 ? coverage : 1.0 - coverage; +} + +void main(void) { + float dist = distToCubicBezier(vPixel, vP0, vP1, vP2, vP3); + + float radius = vWidth * 0.5; + float aa = max(fwidth(dist), 0.75); + + float alpha = 1.0 - smoothstep(radius - aa, radius + aa, dist); + alpha *= clipFactor(vPixel, vClipACenter, vClipARadiusPx); + alpha *= clipFactor(vPixel, vClipBCenter, vClipBRadiusPx); + + if (alpha <= 0.001) { + discard; + } + + fragColor = vec4(vColor.rgb, vColor.a * alpha); + // The discard above already restricts picking to the curve's drawn pixels, so the picking + // pass hit-tests the actual stroke (not the instance's bounding quad). + fragColor = picking_filterPickingColor(fragColor); +} +`; + +// Layer props + +/** + * A single binary instance attribute supplied via `data.attributes`. The + * `value` typed array is uploaded directly to the GPU; `stride`/`offset` + * (both in bytes) allow several attributes to share one interleaved buffer. + */ +interface BinaryAttribute { + readonly value: Float32Array | Uint8Array; + readonly size: number; + readonly stride?: number; + readonly offset?: number; + readonly normalized?: boolean; +} + +/** + * Binary form of `data`: a row count plus pre-packed attributes keyed by + * accessor name. This bypasses the per-datum accessor functions entirely. + */ +interface BinaryData { + readonly length: number; + readonly attributes: Record; +} + +interface BezierSDFLayerProps< + D extends BezierSegmentDatum = BezierSegmentDatum, +> extends LayerProps { + readonly data: readonly D[] | BinaryData; + readonly getP0?: (datum: D) => readonly [number, number]; + readonly getP1?: (datum: D) => readonly [number, number]; + readonly getP2?: (datum: D) => readonly [number, number]; + readonly getP3?: (datum: D) => readonly [number, number]; + readonly getWidth?: (datum: D) => number; + readonly getColor?: (datum: D) => readonly [number, number, number, number]; + readonly getClipA?: (datum: D) => readonly [number, number, number]; + readonly getClipB?: (datum: D) => readonly [number, number, number]; + readonly boundsPaddingPixels?: number; + /** Width unit: `"pixels"` (default), `"common"` (world, scales with zoom), `"meters"`. */ + readonly widthUnits?: Unit; + readonly widthScale?: number; +} + +const DEFAULT_COLOR: readonly [number, number, number, number] = [ + 255, 255, 255, 255, +]; + +const defaultProps = { + getP0: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p0, + }, + getP1: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p1, + }, + getP2: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p2, + }, + getP3: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.p3, + }, + getWidth: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.width, + }, + getColor: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.color, + }, + getClipA: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.clipA ?? [0, 0, 0], + }, + getClipB: { + type: "accessor" as const, + value: (datum: BezierSegmentDatum) => datum.clipB ?? [0, 0, 0], + }, + boundsPaddingPixels: 4, + widthUnits: "pixels" as const, + widthScale: 1, +}; + +export class BezierSDFLayer extends Layer { + static layerName = "BezierSDFLayer"; + static defaultProps = defaultProps; + + getShaders() { + return super.getShaders({ + vs, + fs, + modules: [project32, picking, bezierUniforms], + }); + } + + initializeState() { + const attributeManager = this.getAttributeManager()!; + + attributeManager.addInstanced({ + instanceP0: { + size: 2, + accessor: "getP0", + defaultValue: [0, 0], + }, + instanceP1: { + size: 2, + accessor: "getP1", + defaultValue: [0, 0], + }, + instanceP2: { + size: 2, + accessor: "getP2", + defaultValue: [0, 0], + }, + instanceP3: { + size: 2, + accessor: "getP3", + defaultValue: [0, 0], + }, + instanceWidths: { + size: 1, + accessor: "getWidth", + defaultValue: 4, + }, + instanceColors: { + size: 4, + accessor: "getColor", + type: "unorm8", + defaultValue: DEFAULT_COLOR, + }, + instanceClipA: { + size: 3, + accessor: "getClipA", + defaultValue: [0, 0, 0], + }, + instanceClipB: { + size: 3, + accessor: "getClipB", + defaultValue: [0, 0, 0], + }, + }); + + this.setState({ model: this._getModel() }); + } + + updateState(params: UpdateParameters) { + super.updateState(params); + + if (params.changeFlags.extensionsChanged) { + (this.state as { model?: Model }).model?.destroy(); + this.setState({ model: this._getModel() }); + this.getAttributeManager()?.invalidateAll(); + } + } + + draw() { + const model = (this.state as { model: Model }).model; + const { viewport, renderPass } = this.context; + + model.shaderInputs.setProps({ + bezier: { + uViewportSize: [viewport.width, viewport.height] as [number, number], + uBoundsPaddingPixels: this.props.boundsPaddingPixels ?? 4, + widthUnits: UNIT[this.props.widthUnits ?? "pixels"], + widthScale: this.props.widthScale ?? 1, + }, + }); + + model.draw(renderPass); + } + + finalizeState(context: Parameters[0]) { + (this.state as { model?: Model }).model?.destroy(); + super.finalizeState(context); + } + + _getModel(): Model { + // Two triangles forming a [-1,1] quad. Each Bezier segment + // is instanced onto this quad, which the vertex shader scales + // to the control point bounding box in screen space. + const positions = new Float32Array([ + -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, + ]); + + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: "triangle-list", + attributes: { + positions: { size: 2, value: positions }, + }, + }), + isInstanced: true, + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts new file mode 100644 index 00000000000..70e162cb6c7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts @@ -0,0 +1,294 @@ +/** + * Custom Deck.gl layer: renders community "BubbleSets" as CRISP metaball + * isocontours — one smooth, hard-edged organic hull per Louvain community. + * + * Each INSTANCE is one community: a screen-space quad covering the community's + * world bbox (+ field-radius padding). The fragment shader sums a finite-support + * metaball kernel over THAT community's node centres — read from a positions + * texture via `texelFetch`, using the per-instance [offset, count] range — and + * THRESHOLDS the field (`smoothstep` with `fwidth` AA). The result is a single + * smooth contour that merges nearby nodes and has a hard antialiased edge (no + * muddy gradient overlap), coloured by community. Because each quad sums only its + * OWN community's nodes, communities stay distinct in one pass — no offscreen FBO. + * + * Drawn BEHIND the dots/edges (community-force only). Node positions come from the + * flat SAB (gathered per community into the texture by the presentation layer); + * everything is in `common`/world units, so the contour scales with zoom. Only + * non-trivial communities are bubbled (the caller's size threshold), so the count + * is small and the per-pixel node loop stays cheap. + */ +import { Layer, project32 } from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; + +import type { LayerProps, UpdateParameters } from "@deck.gl/core"; +import type { Texture } from "@luma.gl/core"; +import type { ShaderModule } from "@luma.gl/shadertools"; + +/** Loop bound for the per-pixel node sum (a community larger than this truncates). */ +const MAX_NODES_PER_COMMUNITY = 256; + +interface BubbleUniformProps { + /** Metaball field radius per node, in `common` (world) units. */ + fieldRadius: number; + /** Field value of the isocontour (where the hard edge sits). */ + isoThreshold: number; + /** Width of the positions texture, for texelFetch index → (x, y). */ + texWidth: number; +} + +const bubbleUniforms = { + name: "bubble", + vs: `\ +layout(std140) uniform bubbleUniforms { + float fieldRadius; + float isoThreshold; + highp int texWidth; +} bubble; +`, + fs: `\ +layout(std140) uniform bubbleUniforms { + float fieldRadius; + float isoThreshold; + highp int texWidth; +} bubble; +`, + uniformTypes: { + fieldRadius: "f32", + isoThreshold: "f32", + texWidth: "i32", + }, + defaultUniforms: { + fieldRadius: 55, + isoThreshold: 0.5, + texWidth: 256, + }, +} satisfies ShaderModule; + +const vs = `\ +#version 300 es +#define SHADER_NAME bubble-set-sdf-layer-vertex + +in vec2 positions; + +in vec4 instanceBounds; // minX, minY, maxX, maxY (world) +in vec4 instanceColors; +in vec2 instanceNodeRange; // offset, count into the positions texture + +out vec2 vWorldPos; +out vec4 vColor; +flat out int vOffset; +flat out int vCount; + +void main(void) { + vec2 uv = positions * 0.5 + 0.5; + vec2 worldPos = mix(instanceBounds.xy, instanceBounds.zw, uv); + + vWorldPos = worldPos; + vColor = instanceColors; + vOffset = int(instanceNodeRange.x); + vCount = int(instanceNodeRange.y); + + vec3 projected = project_position(vec3(worldPos, 0.0)); + gl_Position = project_common_position_to_clipspace(vec4(projected, 1.0)); +} +`; + +const fs = `\ +#version 300 es +#define SHADER_NAME bubble-set-sdf-layer-fragment +precision highp float; + +uniform sampler2D positionsTex; + +in vec2 vWorldPos; +in vec4 vColor; +flat in int vOffset; +flat in int vCount; + +out vec4 fragColor; + +void main(void) { + // Sum the finite-support metaball kernel over this community's node centres. + float field = 0.0; + for (int i = 0; i < ${MAX_NODES_PER_COMMUNITY}; i++) { + if (i >= vCount) { + break; + } + int idx = vOffset + i; + vec2 nodePos = + texelFetch(positionsTex, ivec2(idx % bubble.texWidth, idx / bubble.texWidth), 0).rg; + float d = distance(vWorldPos, nodePos) / bubble.fieldRadius; + if (d < 1.0) { + // Wyvill-style kernel: 1 at the centre, 0 at the rim, C1-continuous so + // overlapping fields merge into one smooth contour. + float base = 1.0 - d * d; + field += base * base; + } + } + + // Crisp isocontour at isoThreshold, antialiased by the field's screen-space + // gradient -- a hard edge at any zoom, not a soft gradient. + float aa = max(fwidth(field), 1.0e-4); + float alpha = + smoothstep(bubble.isoThreshold - aa, bubble.isoThreshold + aa, field) * vColor.a; + if (alpha <= 0.002) { + discard; + } + fragColor = vec4(vColor.rgb, alpha); +} +`; + +interface BinaryAttribute { + readonly value: Float32Array | Uint8Array; + readonly size: number; + readonly stride?: number; + readonly offset?: number; + readonly normalized?: boolean; +} + +interface BinaryData { + readonly length: number; + readonly attributes: Record; +} + +interface BubbleSetSDFLayerProps extends LayerProps { + readonly data: BinaryData; + /** Node centres (world), grouped by community: `texWidth * texHeight` rg32f texels. */ + readonly positions: Float32Array; + readonly texWidth: number; + readonly texHeight: number; + /** Metaball field radius per node, in `common` (world) units. */ + readonly fieldRadius?: number; + readonly isoThreshold?: number; +} + +const defaultProps = { + positions: { type: "object" as const, value: new Float32Array(0) }, + texWidth: { type: "number" as const, value: 256 }, + texHeight: { type: "number" as const, value: 1 }, + fieldRadius: { type: "number" as const, value: 55 }, + isoThreshold: { type: "number" as const, value: 0.5 }, +}; + +interface BubbleLayerState { + model?: Model; + texture?: Texture; +} + +export class BubbleSetSDFLayer extends Layer { + static layerName = "BubbleSetSDFLayer"; + static defaultProps = defaultProps; + + getShaders() { + return super.getShaders({ + vs, + fs, + modules: [project32, bubbleUniforms], + }); + } + + initializeState() { + this.getAttributeManager()!.addInstanced({ + instanceBounds: { + size: 4, + accessor: "getBounds", + defaultValue: [0, 0, 0, 0], + }, + instanceColors: { + size: 4, + accessor: "getColor", + type: "unorm8", + defaultValue: [255, 255, 255, 64], + }, + instanceNodeRange: { + size: 2, + accessor: "getNodeRange", + defaultValue: [0, 0], + }, + }); + this.setState({ model: this._getModel() }); + } + + updateState(params: UpdateParameters) { + super.updateState(params); + const { changeFlags, props, oldProps } = params; + + if (changeFlags.extensionsChanged) { + (this.state as BubbleLayerState).model?.destroy(); + this.setState({ model: this._getModel() }); + this.getAttributeManager()?.invalidateAll(); + } + + if ( + props.positions !== oldProps.positions || + props.texWidth !== oldProps.texWidth || + props.texHeight !== oldProps.texHeight + ) { + this._updateTexture(); + } + } + + draw() { + const state = this.state as BubbleLayerState; + const { model, texture } = state; + if (!model || !texture) { + return; + } + model.setBindings({ positionsTex: texture }); + model.shaderInputs.setProps({ + bubble: { + fieldRadius: this.props.fieldRadius ?? 55, + isoThreshold: this.props.isoThreshold ?? 0.5, + texWidth: this.props.texWidth, + }, + }); + model.draw(this.context.renderPass); + } + + finalizeState(context: Parameters[0]) { + const state = this.state as BubbleLayerState; + state.texture?.destroy(); + state.model?.destroy(); + super.finalizeState(context); + } + + /** (Re)upload the per-community-grouped node positions as an rg32float texture. */ + _updateTexture() { + const state = this.state as BubbleLayerState; + state.texture?.destroy(); + state.texture = this.context.device.createTexture({ + format: "rg32float", + width: this.props.texWidth, + height: this.props.texHeight, + data: this.props.positions, + sampler: { minFilter: "nearest", magFilter: "nearest" }, + }); + } + + _getModel(): Model { + const positions = new Float32Array([ + -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, + ]); + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: "triangle-list", + attributes: { + positions: { size: 2, value: positions }, + }, + }), + isInstanced: true, + // A community hull is a pure BACKDROP -- drawn first, behind everything, never occluding. + // So it must NOT write depth: each instance fills its community's bounding QUAD and only the + // metaball hull inside is opaque, so with depth-write on the whole (mostly transparent) quad + // would stamp the depth buffer and the coplanar bezier edges drawn afterward would fail the + // depth test across that rectangle and vanish. depthCompare "always" as nothing is behind it. + parameters: { + depthWriteEnabled: false, + depthCompare: "always", + }, + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/endpoint-v-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/endpoint-v-layer.ts new file mode 100644 index 00000000000..7cad2ee6a00 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/endpoint-v-layer.ts @@ -0,0 +1,237 @@ +/** + * Instanced endpoint V caps for flat-tier directed edges. + * + * The geometry is one static two-arm V mesh. Each endpoint arrow only supplies + * position, angle, size, and color; the vertex shader applies the per-instance + * transform. This avoids both PolygonLayer/earcut and the doubled source/target + * buffer expansion that a generic LineLayer V would need. + */ +import { Layer, project32 } from "@deck.gl/core"; +import { Geometry, Model } from "@luma.gl/engine"; + +import type { Color } from "../../frames"; +import type { LayerProps, UpdateParameters } from "@deck.gl/core"; + +interface EndpointVDatum { + readonly x: number; + readonly y: number; + readonly angle: number; + readonly size: number; + readonly chord: number; + readonly color: Color; +} + +const vs = `\ +#version 300 es +#define SHADER_NAME endpoint-v-layer-vertex + +in vec2 positions; + +in vec2 instancePositions; +in float instanceAngles; +in float instanceSizes; +in vec4 instanceColors; + +out vec4 vColor; + +void main(void) { + float c = cos(instanceAngles); + float s = sin(instanceAngles); + vec2 local = positions * instanceSizes; + vec2 worldOffset = vec2( + local.x * c - local.y * s, + local.x * s + local.y * c + ); + vec2 worldPosition = instancePositions + worldOffset; + + vec3 projected = project_position(vec3(worldPosition, 0.0)); + gl_Position = project_common_position_to_clipspace(vec4(projected, 1.0)); + vColor = instanceColors; +} +`; + +const fs = `\ +#version 300 es +#define SHADER_NAME endpoint-v-layer-fragment +precision highp float; + +in vec4 vColor; + +out vec4 fragColor; + +void main(void) { + if (vColor.a <= 0.001) { + discard; + } + fragColor = vColor; +} +`; + +interface EndpointVLayerProps< + D extends EndpointVDatum = EndpointVDatum, +> extends LayerProps { + readonly data: readonly D[]; + readonly getPosition?: (datum: D) => readonly [number, number]; + readonly getAngle?: (datum: D) => number; + readonly getSize?: (datum: D) => number; + readonly getColor?: (datum: D) => Color; +} + +const DEFAULT_COLOR: Color = [255, 255, 255, 255]; + +const defaultProps = { + getPosition: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => [datum.x, datum.y] as const, + }, + getAngle: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.angle, + }, + getSize: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.size, + }, + getColor: { + type: "accessor" as const, + value: (datum: EndpointVDatum) => datum.color, + }, +}; + +function endpointVGeometry(): Float32Array { + const length = 1.95; + const halfWidth = 0.96; + const stroke = 0.16; + const innerIncidenceX = -0.38; + const armLength = Math.hypot(length, halfWidth); + const halfStroke = stroke / 2; + + const upperUx = -length / armLength; + const upperUy = halfWidth / armLength; + const upperInnerNx = -upperUy * halfStroke; + const upperInnerNy = upperUx * halfStroke; + const upperBaseX = -length; + const upperBaseY = halfWidth; + const upperInnerBaseX = upperBaseX + upperInnerNx; + const upperInnerBaseY = upperBaseY + upperInnerNy; + const upperOuterBaseX = upperBaseX - upperInnerNx; + const upperOuterBaseY = upperBaseY - upperInnerNy; + + const lowerUx = -length / armLength; + const lowerUy = -halfWidth / armLength; + const lowerNormalX = -lowerUy * halfStroke; + const lowerNormalY = lowerUx * halfStroke; + const lowerBaseX = -length; + const lowerBaseY = -halfWidth; + const lowerInnerBaseX = lowerBaseX - lowerNormalX; + const lowerInnerBaseY = lowerBaseY - lowerNormalY; + const lowerOuterBaseX = lowerBaseX + lowerNormalX; + const lowerOuterBaseY = lowerBaseY + lowerNormalY; + + return new Float32Array([ + 0, + 0, + upperOuterBaseX, + upperOuterBaseY, + upperInnerBaseX, + upperInnerBaseY, + 0, + 0, + upperInnerBaseX, + upperInnerBaseY, + innerIncidenceX, + 0, + 0, + 0, + innerIncidenceX, + 0, + lowerInnerBaseX, + lowerInnerBaseY, + 0, + 0, + lowerInnerBaseX, + lowerInnerBaseY, + lowerOuterBaseX, + lowerOuterBaseY, + ]); +} + +export class EndpointVLayer extends Layer { + static layerName = "EndpointVLayer"; + static defaultProps = defaultProps; + + getShaders() { + return super.getShaders({ + vs, + fs, + modules: [project32], + }); + } + + initializeState() { + const attributeManager = this.getAttributeManager()!; + + attributeManager.addInstanced({ + instancePositions: { + size: 2, + accessor: "getPosition", + defaultValue: [0, 0], + }, + instanceAngles: { + size: 1, + accessor: "getAngle", + defaultValue: 0, + }, + instanceSizes: { + size: 1, + accessor: "getSize", + defaultValue: 1, + }, + instanceColors: { + size: 4, + accessor: "getColor", + type: "unorm8", + defaultValue: DEFAULT_COLOR, + }, + }); + + this.setState({ model: this._getModel() }); + } + + updateState(params: UpdateParameters) { + super.updateState(params); + + if (params.changeFlags.extensionsChanged) { + (this.state as { model?: Model }).model?.destroy(); + this.setState({ model: this._getModel() }); + this.getAttributeManager()?.invalidateAll(); + } + } + + draw() { + const model = (this.state as { model: Model }).model; + const { renderPass } = this.context; + + model.draw(renderPass); + } + + finalizeState(context: Parameters[0]) { + (this.state as { model?: Model }).model?.destroy(); + super.finalizeState(context); + } + + _getModel(): Model { + return new Model(this.context.device, { + ...this.getShaders(), + id: this.props.id, + bufferLayout: this.getAttributeManager()!.getBufferLayouts(), + geometry: new Geometry({ + topology: "triangle-list", + attributes: { + positions: { size: 2, value: endpointVGeometry() }, + }, + }), + isInstanced: true, + }); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts new file mode 100644 index 00000000000..f53c9782c6b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts @@ -0,0 +1,298 @@ +/** + * Async, incrementally-built rasterised icon atlas for the flat-tier type-icon IconLayer. + * + * A single 2D canvas is a grid of fixed-size cells; each unique icon key is rasterised ONCE + * into a free cell and recorded in the mapping ({key -> {x, y, width, height}}). The IconLayer + * samples cells from this canvas (uploaded to a GPU texture) by key. {@link version} bumps on + * ANY change (a new cell, a finished async raster, a canvas grow) so the layer's getIcon + * updateTrigger re-evaluates and a freshly-ready icon appears. + * + * Icon formats mirror {@link "@hashintel/design-system".EntityOrTypeIcon}: + * - a URL (`http(s)://` or `/`) is a monochrome SVG drawn as a WHITE silhouette (recoloured + * via `source-in`), loaded ASYNCHRONOUSLY -- it is pending (not in the mapping, {@link has} + * false) until the image resolves, so the layer simply draws no icon for it meanwhile, then + * it appears on the version bump. A load error drops the key (it just never shows an icon). + * - any other short string is an emoji, drawn SYNCHRONOUSLY with `fillText`. + * + * The cells are pre-coloured (white silhouettes / full-colour emoji), so the layer draws with + * `getColor: [255,255,255,255]`. + * + * The atlas also owns the GPU texture lifecycle: deck v9's `IconLayer.iconAtlas` wants a luma + * `Texture` (it does NOT accept a canvas), and the texture must be built with the SAME device + * the layer renders on, so {@link getTexture} lazily (re)builds it from the canvas keyed on + * {@link version} -- the atlas knows precisely when its pixels changed. + */ +import type { Device, Texture } from "@luma.gl/core"; + +/** A rasterised icon's cell rectangle within the atlas canvas, in device pixels. */ +export interface AtlasCell { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** Side length (device px) of one square atlas cell -- the rasterisation resolution per icon. */ +const CELL_SIZE = 64; + +/** + * Cells per row. FIXED for the atlas's life so a slot's (col, row) -- and thus its mapping + * rectangle -- never changes; growth only ADDS ROWS (a taller canvas), so the existing pixels copy + * across with one `drawImage(previous, 0, 0)` and every recorded cell stays valid. + */ +const COLUMNS = 8; + +/** Initial row count; the canvas grows by {@link ROW_GROWTH} rows whenever the grid fills. */ +const INITIAL_ROWS = 8; +const ROW_GROWTH = 8; + +/** Emoji glyph size within a cell (leaves a small margin so it never clips the cell edge). */ +const EMOJI_FONT_PX = Math.round(CELL_SIZE * 0.8); + +/** Whether an icon string is a URL (a mask/silhouette) vs an emoji -- mirrors EntityOrTypeIcon. */ +function isUrlIcon(icon: string): boolean { + return ( + icon.startsWith("http://") || + icon.startsWith("https://") || + icon.startsWith("/") + ); +} + +/** Resolve a possibly root-relative icon URL against the page origin (as EntityOrTypeIcon does). */ +function resolveIconUrl(icon: string): string { + return icon.startsWith("/") + ? new URL(icon, window.location.origin).href + : icon; +} + +/** + * One claimed cell. `cellIndex` is the stable row-major slot (assigned in claim order and never + * reused), so a canvas grow recomputes every rectangle from these indices unambiguously. `ready` + * flips true once the pixels are drawn; only ready cells are exposed in the deck `iconMapping`. + */ +interface AtlasEntry { + readonly cellIndex: number; + ready: boolean; +} + +export class IconAtlas { + /** Bumped on ANY change (new cell, finished async raster, canvas grow). */ + #version = 0; + /** The backing canvas; a grid of {@link CELL_SIZE} cells, uploaded to the GPU by the layer. */ + #canvas: HTMLCanvasElement; + #ctx: CanvasRenderingContext2D; + /** Current row count (columns are fixed at {@link COLUMNS}); grows by {@link ROW_GROWTH}. */ + #rows = INITIAL_ROWS; + /** Next free cell slot (row-major); never decreases, so slots are stable across grows. */ + #nextCell = 0; + /** Every claimed key (ready or pending) -> its stable slot + ready flag. */ + readonly #entries = new Map(); + /** Fired after an async raster finishes (so the Scene re-pushes the layers). */ + readonly #onUpdate: () => void; + /** Cached GPU texture + the atlas version + device it was built from (rebuilt when stale). */ + #texture: Texture | undefined; + #textureVersion = -1; + #textureDevice: Device | undefined; + /** + * Cached deck `iconMapping` + the version it reflects. A per-frame layer build then reuses one + * object identity until the atlas actually changes, so deck does not re-process the mapping each + * frame (it diffs `iconMapping` by identity). + */ + #mapping: Record | undefined; + #mappingVersion = -1; + + constructor(onUpdate: () => void) { + this.#onUpdate = onUpdate; + this.#canvas = document.createElement("canvas"); + this.#canvas.width = COLUMNS * CELL_SIZE; + this.#canvas.height = this.#rows * CELL_SIZE; + this.#ctx = this.#contextOf(this.#canvas); + } + + get version(): number { + return this.#version; + } + + /** The backing canvas (exposed mainly for tests; the layer uses {@link getTexture}). */ + get canvas(): HTMLCanvasElement { + return this.#canvas; + } + + /** + * The atlas as a GPU texture on `device`, rebuilt from the canvas whenever {@link version} or + * the device changed since the last build (a grown canvas changes both the pixels and the + * texture dimensions). Returns the cached texture otherwise. The destroyed-on-rebuild old + * texture is fine: the IconLayer reads `iconAtlas` fresh each render via the version trigger. + */ + getTexture(device: Device): Texture { + if ( + this.#texture === undefined || + this.#textureVersion !== this.#version || + this.#textureDevice !== device + ) { + this.#texture?.destroy(); + this.#texture = device.createTexture({ + data: this.#canvas, + width: this.#canvas.width, + height: this.#canvas.height, + sampler: { minFilter: "linear", magFilter: "linear" }, + }); + this.#textureVersion = this.#version; + this.#textureDevice = device; + } + return this.#texture; + } + + /** The deck.gl `iconMapping`: every READY key -> its cell rectangle. Cached by version so the + * returned identity is stable between atlas changes. */ + getMapping(): Record { + if (this.#mapping !== undefined && this.#mappingVersion === this.#version) { + return this.#mapping; + } + const mapping: Record = {}; + for (const [key, entry] of this.#entries) { + if (entry.ready) { + mapping[key] = this.#cellRect(entry.cellIndex); + } + } + this.#mapping = mapping; + this.#mappingVersion = this.#version; + return mapping; + } + + /** Is `key` rasterised and ready to draw? A pending/unknown key is not. */ + has(key: string): boolean { + return this.#entries.get(key)?.ready === true; + } + + /** + * Ensure each key is rasterised (or in flight). Emoji keys raster synchronously here; URL keys + * claim a cell, mark pending, and raster on image load. Already-claimed keys (ready OR pending) + * are skipped, so each unique icon is rasterised exactly once. + */ + ensureIcons(keys: readonly string[]): void { + for (const key of keys) { + if (key.length === 0 || this.#entries.has(key)) { + continue; + } + if (isUrlIcon(key)) { + this.#rasterizeUrl(key); + } else { + this.#rasterizeEmoji(key); + } + } + } + + #contextOf(canvas: HTMLCanvasElement): CanvasRenderingContext2D { + const ctx = canvas.getContext("2d"); + if (ctx === null) { + throw new Error("IconAtlas: 2D canvas context unavailable"); + } + return ctx; + } + + /** Claim the next free slot, growing the canvas first if the grid is full. */ + #claimSlot(): number { + if (this.#nextCell >= COLUMNS * this.#rows) { + this.#grow(); + } + const slot = this.#nextCell; + this.#nextCell += 1; + return slot; + } + + /** + * Add rows: re-allocate a TALLER canvas (columns fixed) and copy the existing pixels with a + * single blit. Because columns are unchanged, every slot keeps its (col, row), so the recorded + * cell rectangles stay valid and only the canvas height -- and thus the texture -- grows. + */ + #grow(): void { + const previous = this.#canvas; + this.#rows += ROW_GROWTH; + const next = document.createElement("canvas"); + next.width = COLUMNS * CELL_SIZE; + next.height = this.#rows * CELL_SIZE; + const ctx = this.#contextOf(next); + ctx.drawImage(previous, 0, 0); + this.#canvas = next; + this.#ctx = ctx; + this.#version += 1; + } + + /** The cell rectangle for a row-major slot index (columns fixed at {@link COLUMNS}). */ + #cellRect(slot: number): AtlasCell { + const col = slot % COLUMNS; + const row = Math.floor(slot / COLUMNS); + return { + x: col * CELL_SIZE, + y: row * CELL_SIZE, + width: CELL_SIZE, + height: CELL_SIZE, + }; + } + + #rasterizeEmoji(key: string): void { + const slot = this.#claimSlot(); + const cell = this.#cellRect(slot); + const ctx = this.#ctx; + ctx.save(); + ctx.clearRect(cell.x, cell.y, cell.width, cell.height); + ctx.font = `${EMOJI_FONT_PX}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(key, cell.x + cell.width / 2, cell.y + cell.height / 2); + ctx.restore(); + this.#entries.set(key, { cellIndex: slot, ready: true }); + this.#version += 1; + } + + #rasterizeUrl(key: string): void { + const slot = this.#claimSlot(); + const entry: AtlasEntry = { cellIndex: slot, ready: false }; + this.#entries.set(key, entry); + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + // The canvas may have grown while this loaded; the slot is stable, so recompute its rect. + this.#drawSilhouette(image, this.#cellRect(entry.cellIndex)); + entry.ready = true; + this.#version += 1; + this.#onUpdate(); + }; + image.onerror = () => { + // Drop it: the key stays not-ready, never enters the mapping, so the layer simply never + // shows an icon for it. (Its slot is spent -- a blank cell -- which is acceptable.) + this.#entries.delete(key); + }; + image.src = resolveIconUrl(key); + } + + /** + * Draw `image` fitted into `cell` (preserving aspect), then recolour the drawn pixels to a + * solid WHITE silhouette via `source-in` -- HASH URL icons are monochrome SVGs shown as masks, + * so only the alpha shape matters and the layer tints it via getColor. + */ + #drawSilhouette(image: HTMLImageElement, cell: AtlasCell): void { + const ctx = this.#ctx; + ctx.save(); + ctx.beginPath(); + ctx.rect(cell.x, cell.y, cell.width, cell.height); + ctx.clip(); + ctx.clearRect(cell.x, cell.y, cell.width, cell.height); + const naturalWidth = image.naturalWidth || cell.width; + const naturalHeight = image.naturalHeight || cell.height; + const fit = Math.min( + cell.width / naturalWidth, + cell.height / naturalHeight, + ); + const drawWidth = naturalWidth * fit; + const drawHeight = naturalHeight * fit; + const drawX = cell.x + (cell.width - drawWidth) / 2; + const drawY = cell.y + (cell.height - drawHeight) / 2; + ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight); + ctx.globalCompositeOperation = "source-in"; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(cell.x, cell.y, cell.width, cell.height); + ctx.restore(); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts new file mode 100644 index 00000000000..89ef6ad58ad --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts @@ -0,0 +1,164 @@ +/** + * Text labels, faded by on-screen size so they don't pop in/out at low zoom: cluster labels (by + * bubble radius) and highway connection-count labels (by edge chord length) for the hierarchical + * tier. `zoom` is the current view zoom (`2 ** zoom` = world->pixel scale). + * + * PERF TODO: Use the collision extension instead to automtically cull overlapping labels. + */ +import { TextLayer } from "@deck.gl/layers"; + +import { graphColors } from "../visual-style"; + +import type { + PositionsFrame, + RenderCluster, + RenderEdgeLabel, + StructureFrame, +} from "../frames"; +import type { Layer } from "@deck.gl/core"; + +/** A cluster needs this much screen presence before its circle-contained label can breathe. */ +const CLUSTER_LABEL_MIN_SCREEN_RADIUS = 34; +/** A highway needs enough screen length for its lane-sized count capsule to avoid visual noise. */ +const EDGE_LABEL_MIN_SCREEN_CHORD = 82; +const LABEL_FADE_PX = 18; +/** Line-to-line spacing for multi-line cluster labels (also passed to the TextLayer). */ +const LABEL_LINE_HEIGHT = 1.08; + +function fadeAlpha( + screenMetric: number, + threshold: number, + maxAlpha: number, +): number { + const progress = Math.min( + 1, + Math.max(0, (screenMetric - threshold + LABEL_FADE_PX) / LABEL_FADE_PX), + ); + return Math.round(maxAlpha * progress); +} + +function clusterLabelSize(cluster: RenderCluster): number { + // Property labels arrive multi-line ("Title = value" per line + a "(count)" line); width + // is set by the LONGEST line and the stack must fit the bubble vertically. A single-line + // type-set label (lineCount 1) keeps its original size via the per-line cap. + const lines = cluster.label.split("\n"); + let longest = 1; + for (const line of lines) { + longest = Math.max(longest, line.length); + } + const widthBudget = + cluster.depth > 0 ? cluster.radius * 0.9 : cluster.radius * 1.45; + const perLineCap = + cluster.depth > 0 ? cluster.radius * 0.22 : cluster.radius * 0.34; + const stackBudget = + cluster.depth > 0 ? cluster.radius * 0.62 : cluster.radius * 0.78; + const widthFit = widthBudget / (longest * 0.58); + const heightFit = Math.min( + perLineCap, + stackBudget / (lines.length * LABEL_LINE_HEIGHT), + ); + return Math.min(widthFit, heightFit); +} + +export function clusterLabelLayer( + structure: StructureFrame, + positions: PositionsFrame, + zoom: number, +): Layer | undefined { + const scale = 2 ** zoom; + const data: { cluster: RenderCluster; x: number; y: number }[] = []; + for (let index = 0; index < structure.clusters.length; index++) { + const cluster = structure.clusters[index]!; + data.push({ + cluster, + x: positions.clusterPositions[index * 2] ?? 0, + y: positions.clusterPositions[index * 2 + 1] ?? 0, + }); + } + + return new TextLayer<{ cluster: RenderCluster; x: number; y: number }>({ + id: "cluster-labels", + data, + getPosition: (datum) => [ + datum.x, + datum.cluster.depth > 0 ? datum.y - datum.cluster.radius * 0.82 : datum.y, + ], + getText: (datum) => datum.cluster.label, + getSize: (datum) => clusterLabelSize(datum.cluster), + sizeUnits: "common", + lineHeight: LABEL_LINE_HEIGHT, + getColor: (datum) => [ + 255, + 255, + 255, + fadeAlpha( + datum.cluster.radius * scale, + CLUSTER_LABEL_MIN_SCREEN_RADIUS, + datum.cluster.depth > 0 ? 210 : 255, + ), + ], + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontFamily: "Inter, sans-serif", + characterSet: "auto", + pickable: false, + updateTriggers: { + getColor: zoom, + }, + }); +} + +export function edgeLabelLayer( + positions: PositionsFrame, + zoom: number, + positionVersion: number, +): Layer | undefined { + if (positions.edgeLabels.length === 0) { + return undefined; + } + const scale = 2 ** zoom; + const data = positions.edgeLabels; + + if (data.length === 0) { + return undefined; + } + return new TextLayer({ + id: "edge-labels", + data, + getPosition: (label) => [label.x, label.y], + getText: (label) => label.text, + getSize: (label) => label.size, + getAngle: (label) => label.angle, + sizeUnits: "common", + getColor: (label) => [ + graphColors.edgeLabelText[0], + graphColors.edgeLabelText[1], + graphColors.edgeLabelText[2], + fadeAlpha(label.chord * scale, EDGE_LABEL_MIN_SCREEN_CHORD, 255), + ], + background: true, + getBackgroundColor: (label) => [ + graphColors.edgeLabelBackground[0], + graphColors.edgeLabelBackground[1], + graphColors.edgeLabelBackground[2], + fadeAlpha( + label.chord * scale, + EDGE_LABEL_MIN_SCREEN_CHORD, + graphColors.edgeLabelBackground[3], + ), + ], + backgroundPadding: [6, 3, 6, 3], + getTextAnchor: "middle", + getAlignmentBaseline: "center", + fontFamily: "Inter, sans-serif", + fontWeight: 600, + characterSet: "auto", + pickable: false, + updateTriggers: { + getColor: zoom, + getBackgroundColor: zoom, + getSize: positionVersion, + getAngle: positionVersion, + }, + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts new file mode 100644 index 00000000000..78b7977d173 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts @@ -0,0 +1,1687 @@ +/** + * Owns the Deck.gl instance and drives it imperatively from the worker handle's subscribe + * stream: structure/position events rebuild the layer set off React, and the camera lives + * in a field (never React state), so settling and pan/zoom never trigger a render. The + * React component is a thin mount/dispose shell over this. + */ +import { Deck, LinearInterpolator, OrthographicView } from "@deck.gl/core"; + +import { BEZIER_NO_LINK } from "../frames"; +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, + leafNodeX, + leafNodeY, +} from "../worker/buffers/position-buffer"; +import { radiusForDegree } from "../worker/entity-style"; +import { + buildPlaced, + clusterBubbleLayer, + clusterEntityLayers, + updatePlaced, +} from "./clusters"; +import { communityLayer } from "./community"; +import { edgeArrowLayer } from "./edge-arrows"; +import { edgeLayer } from "./edges"; +import { flatDotsLayer } from "./flat-dots"; +import { IconAtlas } from "./gpu/icon-atlas"; +import { clusterLabelLayer, edgeLabelLayer } from "./labels"; +import { nodeGeometry, selectionOverlayLayers } from "./selection"; +import { leafTypeIconLayers, typeIconLayer } from "./type-icons"; + +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { ClusterId, EntityIdx } from "../ids"; +import type { EgoTarget } from "../worker/protocol"; +import type { PlacedCluster } from "./clusters"; +import type { Selection, SelectionGeometry } from "./selection"; +import type { WorkerEvent, WorkerHandle } from "./worker-connection"; +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; +import type { Layer, PickingInfo } from "@deck.gl/core"; +import type { Device } from "@luma.gl/core"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- deck.gl view state is loosely typed (target/zoom plus transition metadata passed through verbatim). +type ViewState = Record; + +const FLAT_EDGE_LAYER_ID = "flat-edges"; +const HIERARCHICAL_EDGE_LAYER_ID = "hierarchical-edges"; +const PICKABLE_EDGE_LAYER_IDS = [ + FLAT_EDGE_LAYER_ID, + HIERARCHICAL_EDGE_LAYER_ID, +] as const; + +function isPickableEdgeLayer(layerId: string | undefined): boolean { + return ( + layerId === FLAT_EDGE_LAYER_ID || layerId === HIERARCHICAL_EDGE_LAYER_ID + ); +} + +interface ViewBounds { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} + +// zoomX/zoomY only, never scalar `zoom`: OrthographicView works in zoomX/zoomY internally; +// a stray `zoom` makes transitions compute their start from an inconsistent value and jerk. +const INITIAL_VIEW_STATE: ViewState = { + target: [0, 0, 0], + zoomX: 0, + zoomY: 0, + minZoom: -12, + maxZoom: 24, +}; + +/** + * Minimum on-screen dot DIAMETER (px) for its entity label to be eligible. Under OrthographicView + * one world unit is `2 ** zoom` px, so a dot of `worldRadius` clears this once + * `worldRadius * 2 * 2 ** zoom > ENTITY_LABEL_MIN_SCREEN_DIAMETER`. This is the on-screen-size bar; + * WHICH dots are hubs is a separate by-radius cut (see {@link HUB_LABEL_MIN_RADIUS}). Ordinary + * entities are never labelled; their detail is hover-only (the card). + */ +const ENTITY_LABEL_MIN_SCREEN_DIAMETER = 24; + +/** + * Hub selection for always-on labels. A dot is a hub by its by-degree RADIUS -- the worker's + * authoritative connectivity (it sizes every dot this way, counting links a main-thread prop tally + * would miss, e.g. frontier-expansion links). Eligible = radius of at least a {@link + * HUB_LABEL_MIN_DEGREE}-degree node; of those (and on-screen-large enough), only the largest + * {@link HUB_LABEL_MAX_COUNT} are labelled, so the labels orient the view without crowding it. + */ +const HUB_LABEL_MIN_DEGREE = 4; +const HUB_LABEL_MIN_RADIUS = radiusForDegree(HUB_LABEL_MIN_DEGREE); +const HUB_LABEL_MAX_COUNT = 12; + +/** + * Off-screen margin (px) for culling HTML entity labels: keep a label whose dot sits just past the + * edge (so it shows partially) but drop the rest, so React only renders what's on screen. + */ +const ENTITY_LABEL_CULL_MARGIN_PX = 80; + +/** Gap (px) between a hub dot's edge and its label, which sits to the dot's right. */ +const ENTITY_LABEL_GAP_PX = 6; +const ENTITY_LABEL_MAX_WIDTH_PX = 180; +const ENTITY_LABEL_HEIGHT_PX = 22; +const ENTITY_LABEL_APPROX_CHAR_WIDTH_PX = 7; +const ENTITY_LABEL_COLLISION_PADDING_PX = 4; +/** Recompute label eligibility only when zoom crosses this coarse bucket. */ +const LABEL_ZOOM_BUCKETS_PER_UNIT = 4; +/** Re-evaluate icon visibility/color only on coarse zoom buckets, not every wheel delta. */ +const ICON_ZOOM_BUCKETS_PER_UNIT = 8; +/** Worker-side edge/LOD geometry does not need every tiny wheel delta. */ +const WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT = 16; +const WORKER_VIEWPORT_MAX_FPS = 20; +const WORKER_VIEWPORT_MIN_INTERVAL_MS = 1000 / WORKER_VIEWPORT_MAX_FPS; + +function viewStateZoom(viewState: ViewState): number { + const { zoom, zoomX } = viewState; + if (typeof zoomX === "number") { + return zoomX; + } + if (typeof zoom === "number") { + return zoom; + } + if (Array.isArray(zoom) && typeof zoom[0] === "number") { + return zoom[0]; + } + return 0; +} + +function labelZoomBucket(zoom: number): number { + return Math.floor(zoom * LABEL_ZOOM_BUCKETS_PER_UNIT); +} + +function iconZoomBucket(zoom: number): number { + return Math.floor(zoom * ICON_ZOOM_BUCKETS_PER_UNIT); +} + +function workerViewportZoom(zoom: number): number { + return ( + Math.round(zoom * WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT) / + WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT + ); +} + +class WorkerViewportSnapshot { + zoom = 0; + readonly center: [number, number] = [0, 0]; + centerX = 0; + centerY = 0; + width = 0; + height = 0; + + update(container: HTMLDivElement, viewState: ViewState): void { + const rect = container.getBoundingClientRect(); + const zoom = workerViewportZoom(viewStateZoom(viewState)); + const scale = 2 ** zoom; + // Pan only affects hierarchical LOD when bubble centres cross meaningful screen-space thresholds. + // Quantise to ~32px buckets so erratic drag does not spam equivalent LOD probes. + const centerBucketWorld = 32 / Math.max(scale, 1e-6); + const centerX = viewState.target[0] as number; + const centerY = viewState.target[1] as number; + this.zoom = zoom; + this.centerX = Math.round(centerX / centerBucketWorld) * centerBucketWorld; + this.centerY = Math.round(centerY / centerBucketWorld) * centerBucketWorld; + this.center[0] = this.centerX; + this.center[1] = this.centerY; + this.width = Math.round(rect.width); + this.height = Math.round(rect.height); + } + + copyFrom(other: WorkerViewportSnapshot): void { + this.zoom = other.zoom; + this.centerX = other.centerX; + this.centerY = other.centerY; + this.center[0] = other.centerX; + this.center[1] = other.centerY; + this.width = other.width; + this.height = other.height; + } + + equals(other: WorkerViewportSnapshot | null): boolean { + return ( + other !== null && + this.zoom === other.zoom && + this.centerX === other.centerX && + this.centerY === other.centerY && + this.width === other.width && + this.height === other.height + ); + } +} + +function buildLabelLayers( + structure: StructureFrame, + positions: PositionsFrame, + zoom: number, + positionTick: number, +): Layer[] { + const result: Layer[] = []; + const edgeLabels = edgeLabelLayer(positions, zoom, positionTick); + if (edgeLabels) { + result.push(edgeLabels); + } + const clusterLabels = clusterLabelLayer(structure, positions, zoom); + if (clusterLabels) { + result.push(clusterLabels); + } + return result; +} + +/** A hovered flat-tier entity: its id and the cursor position in container pixels. */ +export interface EntityHover { + readonly entityId: EntityId; + readonly x: number; + readonly y: number; +} + +/** A hovered aggregated highway: a summary of the links it bundles, at the cursor. */ +export interface HighwayHover { + /** The lane's single link type (for the main thread to resolve the icon); null for a rollup. */ + readonly typeId: VersionedUrl | null; + readonly typeLabel: string; + readonly count: number; + readonly direction: "forward" | "reverse" | "both"; + readonly x: number; + readonly y: number; +} + +/** + * A hovered wholly-frontier cluster bubble (every member fetched-but-unexpanded): its frontier + * EntityIds plus the bubble's on-screen geometry, re-emitted as the camera moves / layout settles + * so an action card can sit at its edge and offer to load it. Null on leave. + */ +export interface ClusterHover { + readonly count: number; + readonly frontierEntityIds: readonly EntityId[]; + /** Bubble centre in container pixels. */ + readonly x: number; + readonly y: number; + /** Bubble on-screen radius (px), so the card can sit just outside its edge. */ + readonly radiusPx: number; +} + +/** + * The selected entity: its id and its on-screen position in container pixels, re-emitted as + * the node settles and the camera moves so a pinned card can follow it. + */ +export interface EntitySelection { + readonly entityId: EntityId; + readonly x: number; + readonly y: number; +} + +/** + * An always-on entity label to overlay as HTML: the entity, its display name, and its CURRENT + * on-screen position (container pixels). The Scene re-emits the visible set each frame so the + * labels track the camera / settling layout; React renders them over the canvas (viewport-culled), + * so they read in the hash-frontend design language rather than as GPU text. + */ +export interface EntityLabel { + readonly entityId: EntityId; + readonly text: string; + readonly x: number; + readonly y: number; +} + +/** + * The cached set of which flat-tier dots carry an always-on (hub) label, with the resolved text + entity. + * Rebuilt ONLY on a zoom / structure change ({@link Scene["#rebuildEntityLabelData"]} -- the perf + * rule); each frame the Scene projects each datum's LIVE SAB position and emits the on-screen ones + * as {@link EntityLabel}s. `recordIndex` is the render index in the layout's SAB as it was scanned. + */ +interface EntityLabelDatum { + readonly layoutId: ClusterId; + readonly recordIndex: number; + readonly entityId: EntityId; + readonly text: string; + /** The dot's world radius, so the label can sit just below the dot's edge at any zoom. */ + readonly worldRadius: number; +} + +/** + * A collapsed-cluster ego neighbor, by bubble id: the only ego target we ring + keep at full + * colour, since visible entity neighbors read as the un-dimmed dots. Geometry is read live each + * frame so the overlay tracks motion. + */ +type EgoRef = { readonly clusterId: ClusterId }; + +export interface SceneCallbacks { + /** Report the hovered flat-tier entity, or null on leave. */ + readonly onEntityHover?: (hover: EntityHover | null) => void; + /** Report a hovered aggregated highway's summary, or null on leave. */ + readonly onHighwayHover?: (hover: HighwayHover | null) => void; + /** Report the selected entity + its tracked on-screen position, or null when cleared. */ + readonly onEntitySelect?: (selection: EntitySelection | null) => void; + /** Open a table of the link entities aggregated by a clicked highway (hierarchical tier). */ + readonly onOpenLinkTable?: (linkEntityIds: readonly EntityId[]) => void; + /** Report a hovered wholly-frontier cluster bubble (offer to load its entities), or null on leave. */ + readonly onClusterHover?: (hover: ClusterHover | null) => void; + /** + * Resolve an entity's display label (its name) for the always-on graph labels. Called only + * while the label SET is (re)built -- on a zoom / structure change -- never per frame. + */ + readonly resolveEntityLabel?: (entityId: EntityId) => string | undefined; + /** + * Resolve an entity's type icon to an atlas KEY (an emoji or an image URL), or null for none + * (a ReactElement / absent icon has no atlas entry). Called only while the icon SET is + * (re)built -- on a structure change -- never per frame. See {@link Scene["#rebuildEntityIconData"]}. + */ + readonly resolveEntityIcon?: (entityId: EntityId) => string | null; + /** + * Report the always-on entity (hub) labels to overlay as HTML, re-emitted each frame with their + * current on-screen positions (so they track the camera / settle). Empty when none are visible. + */ + readonly onEntityLabels?: (labels: readonly EntityLabel[]) => void; + /** Fired once, when the first structure frame lands (to drop the loading overlay). */ + readonly onFirstStructure: () => void; +} + +export class Scene { + readonly #deck: Deck; + readonly #handle: WorkerHandle; + readonly #container: HTMLDivElement; + readonly #unsubscribe: () => void; + + #callbacks: SceneCallbacks; + #viewState: ViewState = INITIAL_VIEW_STATE; + #dataLayers: Layer[] = []; + #labelLayers: Layer[] = []; + #labelZoomBucket = labelZoomBucket(viewStateZoom(INITIAL_VIEW_STATE)); + #iconZoomBucket = iconZoomBucket(viewStateZoom(INITIAL_VIEW_STATE)); + /** + * The always-on entity-label SET + resolved text. Rebuilt ONLY on a zoom or structure change + * (the perf rule -- never an O(n) scan or a label resolve on a pan / position frame); the + * TextLayer then reads each dot's LIVE position from the SAB per-datum on `#positionTick`. + */ + #entityLabelData: EntityLabelDatum[] = []; + /** + * Per-render-index type-icon atlas key (or null), for the flat tier, in the layout SAB's record + * order as it was last scanned. Rebuilt ONLY on a structure change / resolver change (the perf + * rule -- the only O(dots) icon-resolution scan), exactly like {@link #entityLabelData}; the + * IconLayer indexes it by render index. {@link #entityIconNamesVersion} bumps with it to drive + * the layer's getIcon trigger. + */ + #entityIconNames: (string | null)[] = []; + /** + * Per open hierarchical leaf (keyed by `layoutId`), the per-local-index type-icon atlas key (or + * null), in the leaf SAB's record order. The hierarchical counterpart of {@link #entityIconNames}; + * scanned in the same {@link Scene["#rebuildEntityIconData"]} pass and sharing its version. + */ + #leafIconNames = new Map(); + #entityIconNamesVersion = 0; + /** + * The rasterised type-icon atlas (emoji + URL silhouettes) feeding the type-icon IconLayers (flat + * tier + per-leaf hierarchical). Async rasters bump its version + call back into + * {@link #refreshDataLayers} so a finished icon appears. + */ + readonly #iconAtlas: IconAtlas; + /** The Deck GPU device, captured once it initialises; the atlas texture is built on it. */ + #device: Device | undefined; + /** Scrim + ego overlay, pushed above data + labels: dims the field, redraws the ego bright. */ + #overlayLayers: Layer[] = []; + #positionTick = 0; + /** Bumped when the selection/ego changes, to re-evaluate the cluster-bubble focus dim. */ + #highlightTick = 0; + /* Used in conjunction with highlightTick, if they are the same, then ego has been resolved */ + #highlightVersion = 0; + /** Highlighted entities (selection + visible ego), mirroring what was sent to the worker, so + * the internal leaf-edge lines dim in step with the dots they connect. */ + #highlightedEntities: ReadonlySet = new Set(); + #hoveredEntity: EntityId | null = null; + /** + * The hovered highway's lane + the hovered point in WORLD space. Kept so the summary card tracks + * the highway as the camera pans / the layout settles -- re-projected by {@link #emitHighwayHover} + * on every view change + frame, the same way the pinned selection card tracks its node. + */ + #hoveredHighway: { laneId: number; worldX: number; worldY: number } | null = + null; + + /** + * The hovered wholly-frontier cluster bubble, by id. Kept so the load card tracks the bubble as + * the camera pans / the layout settles -- re-projected by {@link #emitClusterHover} on every view + * change + frame, like {@link #hoveredHighway}. Its frontier data is read live from {@link #placed}. + */ + #hoveredClusterId: ClusterId | null = null; + + /** The selected entity dot: ring + camera focus + pinned card. Set on click. */ + #selected: Selection | null = null; + /** A selected link EDGE (the link's own EntityIdx): a pinned card + Open, no ring/dim (a link + * isn't a node to focus). Tracked by re-finding its bezier each emit. Excludes #selected. */ + #selectedLink: EntityIdx | null = null; + /** The selected node's ego (neighbors' visible representatives: dots and/or bubbles). */ + #egoTargets: EgoRef[] = []; + + #firstStructureSeen = false; + #viewportFrame: number | null = null; + #viewportTimer: number | null = null; + #viewportDirty = false; + readonly #nextViewport = new WorkerViewportSnapshot(); + readonly #lastViewport = new WorkerViewportSnapshot(); + #hasLastViewport = false; + #lastViewportSentAt = 0; + #entityLabelsFrame: number | null = null; + #isDragging = false; + /** Persistent cluster-bubble set: rebuilt on structure, positions mutated in place. */ + #placed: PlacedCluster[] = []; + + constructor( + container: HTMLDivElement, + handle: WorkerHandle, + callbacks: SceneCallbacks, + ) { + this.#container = container; + this.#handle = handle; + this.#callbacks = callbacks; + // A finished async icon raster bumps the atlas version and re-pushes the layers so the + // newly-ready icon appears (it was simply absent until now). + this.#iconAtlas = new IconAtlas(() => this.#refreshDataLayers()); + + this.#deck = new Deck({ + parent: container, + views: new OrthographicView({ id: "main", controller: true }), + controller: true, + viewState: this.#viewState, + // Capture the GPU device so the icon atlas can build its texture on it; re-push once it is + // available in case a structure frame (and its icon layer) arrived before init completed. + onDeviceInitialized: (device) => { + this.#device = device; + this.#refreshDataLayers(); + }, + getCursor: ({ isDragging, isHovering }) => { + if (isDragging) { + return "grabbing"; + } + return isHovering ? "pointer" : "grab"; + }, + onViewStateChange: ({ viewState }) => + this.#handleViewStateChange(viewState as ViewState), + // A pan begins: hover cards are anchored to moving graph geometry, so hide them until the + // next hover rather than re-projecting React overlays on every drag frame. + onDragStart: () => { + this.#isDragging = true; + this.#clearHover(); + this.#clearHighwayHover(); + this.#clearClusterHover(); + }, + onDragEnd: () => { + this.#isDragging = false; + }, + onClick: (info) => this.#handleClick(info), + onHover: (info) => { + this.#handleHover(info); + }, + layers: [], + }); + + this.#scheduleViewport(); + this.#unsubscribe = handle.subscribe((event) => this.#handleEvent(event)); + } + + /** Refresh the interaction callbacks without re-mounting Deck. */ + setCallbacks(callbacks: SceneCallbacks): void { + this.#callbacks = callbacks; + } + + zoomBy(delta: number): void { + const zoom = viewStateZoom(this.#viewState); + const minZoom = + typeof this.#viewState.minZoom === "number" + ? this.#viewState.minZoom + : -12; + const maxZoom = + typeof this.#viewState.maxZoom === "number" + ? this.#viewState.maxZoom + : 24; + const nextZoom = Math.min(maxZoom, Math.max(minZoom, zoom + delta)); + this.#applyViewState({ + ...this.#viewState, + zoomX: nextZoom, + zoomY: nextZoom, + transitionDuration: 160, + transitionInterpolator: new LinearInterpolator(["zoomX", "zoomY"]), + }); + } + + fitToContent(): void { + const bounds = this.#contentBounds(); + const viewport = this.#deck.getViewports()[0]; + if (!bounds || !viewport) { + return; + } + + const width = Math.max(1, bounds.maxX - bounds.minX); + const height = Math.max(1, bounds.maxY - bounds.minY); + const padding = 72; + const usableWidth = Math.max(1, viewport.width - padding * 2); + const usableHeight = Math.max(1, viewport.height - padding * 2); + const nextZoom = Math.log2( + Math.min(usableWidth / width, usableHeight / height), + ); + const minZoom = + typeof this.#viewState.minZoom === "number" + ? this.#viewState.minZoom + : -12; + const maxZoom = + typeof this.#viewState.maxZoom === "number" + ? this.#viewState.maxZoom + : 24; + const clampedZoom = Math.min(maxZoom, Math.max(minZoom, nextZoom)); + + this.#applyViewState({ + ...this.#viewState, + target: [ + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + 0, + ], + zoomX: clampedZoom, + zoomY: clampedZoom, + transitionDuration: 240, + transitionInterpolator: new LinearInterpolator([ + "target", + "zoomX", + "zoomY", + ]), + }); + } + + dispose(): void { + if (this.#viewportFrame !== null) { + cancelAnimationFrame(this.#viewportFrame); + } + if (this.#viewportTimer !== null) { + clearTimeout(this.#viewportTimer); + } + if (this.#entityLabelsFrame !== null) { + cancelAnimationFrame(this.#entityLabelsFrame); + } + this.#unsubscribe(); + this.#deck.finalize(); + } + + #handleEvent(event: WorkerEvent): void { + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!structure || !positions) { + return; + } + + if (event.kind === "structure") { + // Topology changed: rebuild the bubble set (new array identity -> Deck regenerates + // all bubble attributes). The paired position event does the layer build + push. + this.#placed = buildPlaced(structure, positions); + + // The cut changed: a flat-buffer reorder (or a closed leaf) can invalidate the + // selected index, so keep it only while it still resolves to the same entity. + if ( + this.#selected !== null && + this.#handle.resolveEntityId( + this.#selected.layoutId, + this.#selected.localIndex, + ) !== this.#selected.entityId + ) { + this.#selected = null; + } + + // The visible cut changed: refresh the ego set (or clear it if selection was dropped). + this.#queryEgo(); + + // Structure changed (new dots, reorder, leaf open/close): recompute which dots label + + // their text. The paired position event below rebuilds the layer from this cached set. + this.#rebuildEntityLabelData(); + + // Same gating for the per-dot type-icon keys (the only O(dots) icon-resolution scan). + this.#rebuildEntityIconData(); + + return; + } + + this.#positionTick += 1; + updatePlaced(this.#placed, positions); + this.#dataLayers = this.#buildDataLayers(structure, positions); + this.#rebuildLabels(); + this.#overlayLayers = this.#buildOverlay(); + this.#pushLayers(); + // The selected node may have moved this tick: refresh its tracked screen position. + this.#emitSelection(); + this.#emitHighwayHover(); + this.#emitClusterHover(); + this.#scheduleEntityLabels(); + if (!this.#firstStructureSeen) { + this.#firstStructureSeen = true; + this.#callbacks.onFirstStructure(); + } + } + + // Edges + per-tier layers. Bubbles draw from the persistent `#placed` (so a settling + // frame re-uploads only their positions); flat/hierarchical entity layers build from the + // current frames. + #buildDataLayers( + structure: StructureFrame, + positions: PositionsFrame, + ): Layer[] { + const clusters = this.#handle.getClusters(); + const result: Layer[] = []; + // Layer order is RENDER order (later = on top). Hierarchical edges (feeders/highways) render + // UNDER the faint container bubbles so they read THROUGH them with depth-opacity -- drawn over + // the bubbles they wash out and effectively vanish. Picking is DECOUPLED from this order + // (see #edgePickFor): an edge under a bubble still wins a click/hover, while a dot -- drawn on + // top -- still wins over an edge passing under it. + const edges = edgeLayer(positions, structure.flatGraph !== undefined); + const edgeArrows = edgeArrowLayer( + positions, + viewStateZoom(this.#viewState), + ); + if (structure.flatGraph) { + result.push(...communityLayer(structure.flatGraph, clusters)); + result.push(...edges); + result.push(...edgeArrows); + result.push(...flatDotsLayer(structure.flatGraph, clusters)); + // Type icons sit ON the dots (rendered after them). The device is required to build the atlas + // texture, so before it initialises the icons are simply absent (they appear on the re-push + // from onDeviceInitialized). The hierarchical leaf counterpart is in the other branch. + if (this.#device !== undefined) { + result.push( + ...typeIconLayer({ + graph: structure.flatGraph, + clusters, + atlas: this.#iconAtlas, + device: this.#device, + names: this.#entityIconNames, + namesVersion: this.#entityIconNamesVersion, + positionTick: this.#positionTick, + zoom: this.#iconZoomBucket / ICON_ZOOM_BUCKETS_PER_UNIT, + zoomBucket: this.#iconZoomBucket, + }), + ); + } + } else { + result.push(...edges); + result.push( + clusterBubbleLayer( + this.#placed, + this.#positionTick, + this.#keepFullClusters(), + this.#highlightTick, + ), + ); + result.push(...edgeArrows); + result.push( + ...clusterEntityLayers({ + structure, + positions, + clusters, + positionTick: this.#positionTick, + highlightedEntities: this.#highlightedEntities, + }), + ); + // Type icons sit ON the leaf dots (rendered after them), one IconLayer per open leaf. Gated on + // the device the same way as the flat tier (the atlas texture needs it; absent until init). + if (this.#device !== undefined) { + result.push( + ...leafTypeIconLayers({ + structure, + positions, + clusters, + atlas: this.#iconAtlas, + device: this.#device, + namesByLeaf: this.#leafIconNames, + namesVersion: this.#entityIconNamesVersion, + positionTick: this.#positionTick, + zoom: this.#iconZoomBucket / ICON_ZOOM_BUCKETS_PER_UNIT, + zoomBucket: this.#iconZoomBucket, + }), + ); + } + } + return result; + } + + // The scrim + ego overlay, drawn over the base layers and labels: dims everything, then + // redraws the selected node + its ego (dots, and bubbles for collapsed neighbors) bright on + // top. Empty when nothing is selected. + #buildOverlay(): Layer[] { + // Just the selected node's ring (empty data when nothing is selected, so the layer set + // stays stable). Ego neighbors are conveyed by the focus dim, not rings. + let selected: SelectionGeometry | null = null; + if (this.#selected !== null) { + selected = this.#geometryOf( + this.#selected.layoutId, + this.#selected.localIndex, + ); + } + + return selectionOverlayLayers(selected, this.#positionTick); + } + + #applyViewState(viewState: ViewState): void { + const nextZoom = viewStateZoom(viewState); + const zoomChanged = nextZoom !== viewStateZoom(this.#viewState); + const nextLabelZoomBucket = labelZoomBucket(nextZoom); + const labelEligibilityChanged = + nextLabelZoomBucket !== this.#labelZoomBucket; + const nextIconZoomBucket = iconZoomBucket(nextZoom); + const iconEligibilityChanged = nextIconZoomBucket !== this.#iconZoomBucket; + this.#viewState = viewState; + this.#labelZoomBucket = nextLabelZoomBucket; + this.#iconZoomBucket = nextIconZoomBucket; + this.#deck.setProps({ viewState }); + this.#scheduleViewport(); + // Cluster/edge labels fade by zoom alpha, so their cheap layer props update on zoom. Entity + // label eligibility is the expensive O(dots) path and only changes on coarse zoom buckets. + // GPU layer reconciliation is the expensive path. Only ZOOM needs it (screen-size LOD changes + // which dots label + the label/edge layers); a pure pan reprojects the canvas via viewState, so + // skip the rebuild there. + if (zoomChanged) { + this.#rebuildLabels(); + } + if (labelEligibilityChanged) { + this.#rebuildEntityLabelData(); + } + if (iconEligibilityChanged) { + this.#refreshDataLayers(); + } else if (zoomChanged) { + this.#pushLayers(); + } + // HTML overlays (selection / highway / cluster cards + hub labels) are positioned by PROJECTED + // screen coords, so they must re-project on EVERY camera move -- pan included -- or they freeze + // in place while the canvas slides under them. Cheap: labels are rAF-coalesced and the cards + // update only a GPU transform (their bodies are memoized), so this is safe per drag frame. + this.#emitSelection(); + this.#emitHighwayHover(); + this.#emitClusterHover(); + this.#scheduleEntityLabels(); + } + + #handleViewStateChange(viewState: ViewState): void { + this.#applyViewState(viewState); + } + + #contentBounds(): ViewBounds | null { + const positions = this.#handle.getPositions(); + const structure = this.#handle.getStructure(); + if (!positions || !structure) { + return null; + } + + let bounds: ViewBounds | null = null; + const include = (x: number, y: number, radius: number) => { + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return; + } + const next = { + minX: x - radius, + minY: y - radius, + maxX: x + radius, + maxY: y + radius, + }; + bounds = + bounds === null + ? next + : { + minX: Math.min(bounds.minX, next.minX), + minY: Math.min(bounds.minY, next.minY), + maxX: Math.max(bounds.maxX, next.maxX), + maxY: Math.max(bounds.maxY, next.maxY), + }; + }; + + const flatGraph = structure.flatGraph; + if (flatGraph) { + const flat = this.#handle.getClusters().get(flatGraph.layoutId); + if (flat) { + const data = new DataView(flat.versionView.buffer); + const count = data.getUint32(4, true); + for (let index = 0; index < count; index++) { + const recordOffset = FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES; + include( + data.getFloat32(recordOffset, true), + data.getFloat32(recordOffset + 4, true), + data.getFloat32(recordOffset + FLAT_RADIUS_BYTE_OFFSET, true), + ); + } + } + return bounds; + } + + const clusters = this.#handle.getClusters(); + for (const [index, cluster] of structure.clusters.entries()) { + const x = positions.clusterPositions[index * 2] ?? 0; + const y = positions.clusterPositions[index * 2 + 1] ?? 0; + include(x, y, cluster.radius); + } + + for (const layer of structure.entityLayers) { + const ref = clusters.get(layer.layoutId); + if (!ref) { + continue; + } + const originX = + positions.clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = + positions.clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + for (let index = 0; index < ref.nodeIds.length; index++) { + include( + originX + leafNodeX(ref.positions, index), + originY + leafNodeY(ref.positions, index), + 4, + ); + } + } + + return bounds; + } + + #handleClick(info: PickingInfo): void { + // Entity dot: select it (ring + focus + pinned card). Dots draw on top, so a dot pick wins + // outright. Opening is the card's Open button (wired by the bridge). + const picked = this.#resolvePicked(info); + if (picked) { + this.#select(picked); + return; + } + // Click a flat link edge: pin its card (Open -> slideover), no ring/dim. Click a hierarchical + // highway: open a table of the links it aggregates. Edges render under the bubbles but win the + // click over them (#edgePickFor queries the pickable edge layers when the topmost pick is a + // bubble). + const edgeInfo = this.#edgePickFor(info); + if (edgeInfo) { + const linkEntityIdx = this.#pickedLinkEntityIdx(edgeInfo); + if (linkEntityIdx !== null) { + this.#selectLink(linkEntityIdx); + return; + } + const laneId = this.#pickedHighwayLaneId(edgeInfo); + if (laneId !== null) { + this.#openHighwayLinks(laneId); + return; + } + } + // Cluster bubble: animate so its on-screen radius crosses the open threshold. + const placed = info.object as PlacedCluster | undefined; + if (placed?.cluster) { + const targetZoom = Math.log2(Math.max(1e-3, 320 / placed.cluster.radius)); + const next: ViewState = { + ...this.#viewState, + target: [placed.x, placed.y, 0], + zoomX: targetZoom, + zoomY: targetZoom, + transitionDuration: 450, + transitionInterpolator: new LinearInterpolator([ + "target", + "zoomX", + "zoomY", + ]), + }; + this.#viewState = next; + this.#deck.setProps({ viewState: next }); + return; + } + // Empty space: clear the selection. + this.#select(null); + } + + #handleHover(info: PickingInfo): void { + if (this.#isDragging) { + return; + } + // Entity dot (flat graph or an open hierarchical leaf): show its card at the cursor. Dots draw + // on top, so a dot pick wins outright (and clears any highway summary). + const picked = this.#resolvePicked(info); + if (picked) { + this.#hoveredEntity = picked.entityId; + this.#callbacks.onEntityHover?.({ + entityId: picked.entityId, + x: info.x, + y: info.y, + }); + this.#clearHighwayHover(); + this.#clearClusterHover(); + return; + } + // Edges render under the bubbles but still win a hover over them (#edgePickFor). + const edgeInfo = this.#edgePickFor(info); + if (edgeInfo) { + // Flat edge: a link IS an entity, so show the same card for the link entity. + const linkEntityId = this.#resolvePickedEdge(edgeInfo); + if (linkEntityId !== null) { + this.#hoveredEntity = linkEntityId; + this.#callbacks.onEntityHover?.({ + entityId: linkEntityId, + x: info.x, + y: info.y, + }); + this.#clearHighwayHover(); + this.#clearClusterHover(); + return; + } + // Hierarchical highway: a summary of the links it bundles. + const laneId = this.#pickedHighwayLaneId(edgeInfo); + const lane = + laneId === null + ? undefined + : this.#handle.getStructure()?.highwayLanes[laneId]; + if (laneId !== null && lane && lane.count > 0) { + this.#clearHover(); + this.#clearClusterHover(); + // Anchor the summary to the hovered point in WORLD space so it tracks the highway as the + // camera pans / the layout settles (re-projected by #emitHighwayHover), like the pinned card. + const world = this.#deck.getViewports()[0]?.unproject([info.x, info.y]); + this.#hoveredHighway = + world === undefined + ? null + : { laneId, worldX: world[0] ?? 0, worldY: world[1] ?? 0 }; + this.#emitHighwayHover(); + return; + } + } + // Wholly-frontier cluster bubble (no dot/edge over it): offer to load its entities. Anchored to + // the bubble (re-projected by #emitClusterHover) so the load card tracks it through pan / settle. + const placed = info.object as PlacedCluster | undefined; + const frontierIds = placed?.cluster.frontierEntityIds; + if (placed && frontierIds && frontierIds.length > 0) { + this.#clearHover(); + this.#clearHighwayHover(); + this.#hoveredClusterId = placed.cluster.id; + this.#emitClusterHover(); + return; + } + this.#clearHighwayHover(); + this.#clearHover(); + this.#clearClusterHover(); + } + + /** Clear the load card (cursor left the bubble, or a dot/edge/highway took over). */ + #clearClusterHover(): void { + if (this.#hoveredClusterId === null) { + return; + } + this.#hoveredClusterId = null; + this.#callbacks.onClusterHover?.(null); + } + + /** + * Re-project the hovered frontier bubble to screen and re-emit its load summary, so the card + * tracks the bubble through a pan / settle. Called wherever {@link #emitHighwayHover} is. No-op + * when nothing is hovered or the bubble is no longer a wholly-frontier one. + */ + #emitClusterHover(): void { + if (this.#hoveredClusterId === null) { + return; + } + const placed = this.#placed.find( + (entry) => entry.cluster.id === this.#hoveredClusterId, + ); + const frontierIds = placed?.cluster.frontierEntityIds; + const viewport = this.#deck.getViewports()[0]; + if (!placed || !frontierIds || frontierIds.length === 0 || !viewport) { + return; + } + const projected = viewport.project([placed.x, placed.y]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + return; + } + this.#callbacks.onClusterHover?.({ + count: placed.cluster.count, + frontierEntityIds: frontierIds, + x, + y, + radiusPx: placed.cluster.radius * 2 ** viewStateZoom(this.#viewState), + }); + } + + /** Clear the highway summary (cursor left it, or a dot/link took over). */ + #clearHighwayHover(): void { + this.#hoveredHighway = null; + this.#callbacks.onHighwayHover?.(null); + } + + /** + * Re-project the hovered highway's world anchor to screen and re-emit its summary, so the card + * tracks the highway through a pan / settle. Called wherever {@link #emitSelection} is. No-op + * when nothing is hovered. + */ + #emitHighwayHover(): void { + if (this.#hoveredHighway === null) { + return; + } + const lane = + this.#handle.getStructure()?.highwayLanes[this.#hoveredHighway.laneId]; + const viewport = this.#deck.getViewports()[0]; + if (!lane || lane.count === 0 || !viewport) { + return; + } + const projected = viewport.project([ + this.#hoveredHighway.worldX, + this.#hoveredHighway.worldY, + ]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + return; + } + this.#callbacks.onHighwayHover?.({ + typeId: lane.typeId, + typeLabel: lane.typeLabel, + count: lane.count, + direction: lane.direction, + x, + y, + }); + } + + /** + * The edge pick for a cursor, decoupled from render order. Edges render UNDER the cluster bubbles + * (for the depth-opacity look) but must still win a click/hover over a bubble. If the topmost pick + * is already an edge, use it; if it's a bubble, an edge may sit under it, so query the pickable + * edge layers directly -- deck renders only those layers to the pick buffer, ignoring the bubble + * on top. Returns null over empty space / dots, so the extra pick render happens only when the + * cursor is over a bubble. + */ + #edgePickFor(info: PickingInfo): PickingInfo | null { + if (isPickableEdgeLayer(info.layer?.id)) { + return info; + } + const overBubble = + (info.object as PlacedCluster | undefined)?.cluster !== undefined; + if (!overBubble) { + return null; + } + return this.#deck.pickObject({ + x: info.x, + y: info.y, + radius: 4, + layerIds: [...PICKABLE_EDGE_LAYER_IDS], + }); + } + + // Map a pick on any entity-dot layer to a selection (the entity + the buffer/index it + // resolved from, needed to read its live position). The flat tier is one whole-graph layer + // ("flat-entities"); the hierarchical tier is one layer per open leaf, id + // "entities:". The handle decodes the binary pick index against that buffer. + #resolvePicked(info: PickingInfo): Selection | null { + const layerId = info.layer?.id; + if (layerId === undefined || info.index < 0) { + return null; + } + const structure = this.#handle.getStructure(); + if (!structure) { + return null; + } + let layoutId: ClusterId | undefined; + if (layerId === "flat-entities") { + layoutId = structure.flatGraph?.layoutId; + } else if (layerId.startsWith("entities:")) { + layoutId = structure.entityLayers.find( + (entry) => `entities:${entry.layoutId}` === layerId, + )?.layoutId; + } + if (layoutId === undefined) { + return null; + } + const entityId = this.#handle.resolveEntityId(layoutId, info.index); + return entityId === undefined + ? null + : { entityId, layoutId, localIndex: info.index }; + } + + // Map a pick on a flat edge lane to its link entity (a link is an entity). Bundled flat lanes and + // hierarchical highways carry the BEZIER_NO_LINK sentinel (no single link), so they aren't + // hoverable as entity cards. + #resolvePickedEdge(info: PickingInfo): EntityId | null { + const entityIdx = this.#pickedLinkEntityIdx(info); + return entityIdx === null + ? null + : (this.#handle.entityIdToId(entityIdx) ?? null); + } + + // The link's EntityIdx for a pick on a FLAT-tier edge (there beziers.ids carries the link's + // EntityIdx). Null otherwise -- including the hierarchical tier, where the same channel carries + // an aggregate laneId instead (see #pickedHighwayLaneId). + #pickedLinkEntityIdx(info: PickingInfo): EntityIdx | null { + if ( + info.layer?.id !== FLAT_EDGE_LAYER_ID || + info.index < 0 || + !this.#isFlatMode() + ) { + return null; + } + const id = this.#handle.getPositions()?.beziers.ids[info.index]; + return id === undefined || id === BEZIER_NO_LINK ? null : (id as EntityIdx); + } + + // The aggregate lane id for a pick on a HIERARCHICAL-tier highway (there beziers.ids carries + // the laneId). Null otherwise (flat tier / not an edge / the BEZIER_NO_LINK sentinel). + #pickedHighwayLaneId(info: PickingInfo): number | null { + if ( + info.layer?.id !== HIERARCHICAL_EDGE_LAYER_ID || + info.index < 0 || + this.#isFlatMode() + ) { + return null; + } + const id = this.#handle.getPositions()?.beziers.ids[info.index]; + return id === undefined || id === BEZIER_NO_LINK ? null : id; + } + + #isFlatMode(): boolean { + return this.#handle.getStructure()?.flatGraph !== undefined; + } + + // Hide the hover card (the cursor left the dots, or a pan started under it). + #clearHover(): void { + if (this.#hoveredEntity !== null) { + this.#hoveredEntity = null; + this.#callbacks.onEntityHover?.(null); + } + } + + // Select an entity dot, or clear with null: ring + camera focus + a pinned card. The ring + // is part of the data layers (world-space); the pinned card is React, fed the node's + // tracked screen position via onEntitySelect. + #select(selection: Selection | null): void { + this.#selected = selection; + this.#selectedLink = null; + + // A selection change un-dims immediately; the focus dim re-applies once the ego query + // resolves -- so it never half-applies mid-resolve, and a re-query on a structure frame + // (same selection) leaves the current dim untouched instead of flashing it off. + this.#egoTargets = []; + this.#highlightedEntities = new Set(); + + // We force an early resolve here, so that when we re-render we don't flash the highlight off. + // Because we immediately requery the `egoResolved` will be set to false momentarily. + this.#highlightVersion += 1; + this.#highlightTick = this.#highlightVersion; + + if (selection) { + const geometry = this.#geometryOf( + selection.layoutId, + selection.localIndex, + ); + + if (geometry) { + this.#focusCamera(geometry.x, geometry.y); + } + } + + this.#pinSelection(selection); + this.#queryEgo(); + this.#emitSelection(); + this.#refreshDataLayers(); + } + + // Select a link edge: a pinned card (with Open) that tracks the link's midpoint -- no + // ring/dim/ego (a link isn't a node to focus). Clears any node selection first (un-dims). + #selectLink(linkEntityIdx: EntityIdx): void { + this.#select(null); + this.#selectedLink = linkEntityIdx; + this.#emitSelection(); + } + + // Open the link entities aggregated by a highway lane in a table (the slide-stack). Async: + // the worker maps the laneId to its link EntityIdx set; we resolve those to EntityIds. + #openHighwayLinks(laneId: number): void { + const onOpen = this.#callbacks.onOpenLinkTable; + if (!onOpen) { + return; + } + + void this.#handle.queryHighwayLinks(laneId).then((linkEntityIdxs) => { + const linkEntityIds: EntityId[] = []; + for (const entityIdx of linkEntityIdxs) { + const entityId = this.#handle.entityIdToId(entityIdx); + if (entityId !== undefined) { + linkEntityIds.push(entityId); + } + } + if (linkEntityIds.length > 0) { + onOpen(linkEntityIds); + } + }); + } + + // Pin the selected node's leaf open (hierarchical only -- the flat layout has no LOD) so it + // stays visible as you zoom out for a birds-eye view; clear the pin on deselect. + #pinSelection(selection: Selection | null): void { + if (selection === null) { + this.#handle.setPinned(null); + return; + } + const cluster = this.#handle.getClusters().get(selection.layoutId); + this.#handle.setPinned( + cluster && cluster.flatCapacity === undefined ? selection.layoutId : null, + ); + } + + #isEgoResolved(): boolean { + // We always _first_ increment the highlight version, once we have the result, we flush said version to the tick. Therefore if they are the same, we know the result is fresh. + return this.#highlightVersion === this.#highlightTick; + } + + // Fetch + resolve the selected node's visible neighbors for ego-highlight. Async (a worker + // round-trip); a result that lands after the selection changed is dropped. + #queryEgo(): void { + const selection = this.#selected; + + if (selection === null) { + this.#handle.setHighlight([]); + return; + } + + const entityIdx = this.#handle.entityIdxAt( + selection.layoutId, + selection.localIndex, + ); + + if (entityIdx === undefined) { + // Transient (e.g. the flat buffer reordered mid-resolve); keep the current dim and let + // the next structure frame re-query, rather than flicker it off and back on. + return; + } + + // Bump the version, THEN capture it as THIS query's -- the order is load-bearing: capturing + // before the bump leaves it one behind, so the guard below would drop even this query's own + // result. A later query (re-select / structure re-query) bumps again, so a stale in-flight + // result sees currentVersion !== #highlightVersion and drops; only the most recent applies. + this.#highlightVersion += 1; + const currentVersion = this.#highlightVersion; + + void this.#handle.queryEgo(entityIdx).then((targets) => { + if (this.#selected !== selection) { + return; + } + + this.#egoTargets = this.#resolveEgoTargets(targets); + if (currentVersion !== this.#highlightVersion) { + // While waiting for the result, someone else has already updated the highlight version; + // ignore this result and wait for the next structure frame to re-query. + return; + } + + // The dim set: the selected node + its visible (entity) neighbors stay full colour; + // collapsed-cluster neighbors are not opened (that would defeat the LOD), just dimmed. + const highlighted = [entityIdx]; + for (const target of targets) { + if (target.kind === "entity") { + highlighted.push(target.entityIdx); + } + } + + this.#highlightedEntities = new Set(highlighted); + this.#highlightTick = this.#highlightVersion; + + this.#handle.setHighlight(highlighted); + this.#refreshDataLayers(); + }); + } + + // Resolve the worker's ego targets to renderable refs: cluster targets pass through as a + // bubble id; entity targets are located to their current render index across the open + // layouts (flat or leaves), reading live record order so a reordered flat buffer is handled. + #resolveEgoTargets(targets: readonly EgoTarget[]): EgoRef[] { + const refs: EgoRef[] = []; + for (const target of targets) { + // Only collapsed-cluster neighbors are ring + keep-full targets; entity neighbors read + // as the un-dimmed dots (the worker keeps them at full colour via the highlight set). + if (target.kind === "cluster") { + refs.push({ clusterId: target.clusterId }); + } + } + return refs; + } + + // The clusters to keep at full colour while a selection is active (the ego's collapsed- + // neighbor bubbles); null when nothing is selected. Every other leaf bubble recedes. + #keepFullClusters(): ReadonlySet | null { + if (this.#selected === null || !this.#isEgoResolved()) { + return null; + } + + const keep = new Set(); + for (const ref of this.#egoTargets) { + keep.add(ref.clusterId); + } + return keep; + } + + // Live world position + radius of a node by its layout + render index, or null if gone. + #geometryOf( + layoutId: ClusterId, + localIndex: number, + ): SelectionGeometry | null { + const cluster = this.#handle.getClusters().get(layoutId); + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!cluster || !structure || !positions) { + return null; + } + return nodeGeometry(layoutId, localIndex, cluster, structure, positions); + } + + // World midpoint of a selected link's edge, by re-locating its bezier segment (segment order + // changes per tick, but the link's EntityIdx is stable). null if the link isn't rendered. + #linkMidpoint(linkEntityIdx: EntityIdx): { x: number; y: number } | null { + const positions = this.#handle.getPositions(); + if (!positions) { + return null; + } + const { ids, positions: pos } = positions.beziers; + for (let index = 0; index < ids.length; index++) { + if (ids[index] === linkEntityIdx) { + // Flat links are straight cubics, so the chord midpoint (p0+p3)/2 is the visual centre. + const base = index * 8; + return { + x: ((pos[base] ?? 0) + (pos[base + 6] ?? 0)) / 2, + y: ((pos[base + 1] ?? 0) + (pos[base + 7] ?? 0)) / 2, + }; + } + } + return null; + } + + // Push the selected node's current SCREEN position to React so the pinned card tracks it + // through settle + pan/zoom; emit null only when nothing is selected (a transient missing + // geometry keeps the last position rather than flickering the card off). + #emitSelection(): void { + // The pinned card tracks a selected NODE (its dot) or a selected LINK (its edge midpoint). + let world: { x: number; y: number } | null = null; + let entityId: EntityId | undefined; + if (this.#selected !== null) { + world = this.#geometryOf( + this.#selected.layoutId, + this.#selected.localIndex, + ); + entityId = this.#selected.entityId; + } else if (this.#selectedLink !== null) { + world = this.#linkMidpoint(this.#selectedLink); + entityId = this.#handle.entityIdToId(this.#selectedLink); + } + if (world === null || entityId === undefined) { + // Nothing selected -> clear the card; a transiently missing geometry keeps the last + // position rather than flickering the card off. + if (this.#selected === null && this.#selectedLink === null) { + this.#callbacks.onEntitySelect?.(null); + } + return; + } + const viewport = this.#deck.getViewports()[0]; + if (!viewport) { + return; + } + const projected = viewport.project([world.x, world.y]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + return; + } + this.#callbacks.onEntitySelect?.({ entityId, x, y }); + } + + // Ease the camera to centre a point, holding the current zoom. + #focusCamera(x: number, y: number): void { + const next: ViewState = { + ...this.#viewState, + target: [x, y, 0], + transitionDuration: 350, + transitionInterpolator: new LinearInterpolator(["target"]), + }; + this.#viewState = next; + this.#deck.setProps({ viewState: next }); + } + + // Rebuild + push the data layers against the current frames (e.g. after a selection change + // while the layout is settled, when no position event would otherwise refresh the ring). + #refreshDataLayers(): void { + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!structure || !positions) { + return; + } + this.#dataLayers = this.#buildDataLayers(structure, positions); + this.#overlayLayers = this.#buildOverlay(); + this.#pushLayers(); + } + + #pushLayers(): void { + this.#deck.setProps({ + layers: [ + ...this.#dataLayers, + ...this.#labelLayers, + ...this.#overlayLayers, + ], + }); + } + + #scheduleViewport(): void { + this.#viewportDirty = true; + if (this.#viewportFrame !== null || this.#viewportTimer !== null) { + return; + } + const elapsed = performance.now() - this.#lastViewportSentAt; + const delay = Math.max(0, WORKER_VIEWPORT_MIN_INTERVAL_MS - elapsed); + const scheduleFrame = () => { + this.#viewportFrame = requestAnimationFrame(() => { + this.#viewportFrame = null; + this.#emitViewport(); + }); + }; + if (delay === 0) { + scheduleFrame(); + } else { + this.#viewportTimer = window.setTimeout(() => { + this.#viewportTimer = null; + scheduleFrame(); + }, delay); + } + } + + #emitViewport(): void { + if (!this.#viewportDirty) { + return; + } + this.#viewportDirty = false; + this.#nextViewport.update(this.#container, this.#viewState); + if ( + this.#hasLastViewport && + this.#nextViewport.equals(this.#lastViewport) + ) { + return; + } + this.#lastViewport.copyFrom(this.#nextViewport); + this.#hasLastViewport = true; + this.#lastViewportSentAt = performance.now(); + this.#handle.sendViewport({ + zoom: this.#lastViewport.zoom, + center: this.#lastViewport.center, + width: this.#lastViewport.width, + height: this.#lastViewport.height, + }); + } + + #rebuildLabels(): void { + const structure = this.#handle.getStructure(); + const positions = this.#handle.getPositions(); + if (!structure || !positions) { + return; + } + + // Cluster + edge labels only. The entity-label layer is built fresh in #pushLayers instead, + // so it rides the CURRENT #positionTick and tracks the settling layout (live SAB reads) -- + // #rebuildLabels only fires on zoom / structure, which would freeze labels mid-settle. + this.#labelLayers = buildLabelLayers( + structure, + positions, + viewStateZoom(this.#viewState), + this.#positionTick, + ); + } + + /** + * The live world position of a labelled dot, read from the SAB via the SAME byte math the + * selection ring uses ({@link nodeGeometry}). Called per-datum by {@link #emitEntityLabels}; + * returns null for a transiently-missing record (the set is rebuilt next zoom / structure), so + * that label is simply skipped this frame. + */ + #readLabelPosition( + datum: EntityLabelDatum, + ): readonly [number, number] | null { + const geometry = this.#geometryOf(datum.layoutId, datum.recordIndex); + return geometry === null ? null : [geometry.x, geometry.y]; + } + + /** + * Project the cached hub-label set to screen and emit the on-screen ones for React to overlay as + * HTML. Called wherever positions change (frame + view change), so the labels track the camera / + * settle -- like {@link #emitSelection}. The SET (which hubs + text) is NOT recomputed here (that + * is the gated {@link #rebuildEntityLabelData}); only positions re-project, bounded by the set. + */ + #scheduleEntityLabels(): void { + if (this.#entityLabelsFrame !== null) { + return; + } + this.#entityLabelsFrame = requestAnimationFrame(() => { + this.#entityLabelsFrame = null; + this.#emitEntityLabels(); + }); + } + + #emitEntityLabels(): void { + const onLabels = this.#callbacks.onEntityLabels; + if (onLabels === undefined) { + return; + } + const viewport = this.#deck.getViewports()[0]; + if (!viewport) { + return; + } + const margin = ENTITY_LABEL_CULL_MARGIN_PX; + const maxX = viewport.width + margin; + const maxY = viewport.height + margin; + const scale = 2 ** viewStateZoom(this.#viewState); + const labels: EntityLabel[] = []; + const occupied: { + readonly left: number; + readonly right: number; + readonly top: number; + readonly bottom: number; + }[] = []; + for (const datum of this.#entityLabelData) { + const world = this.#readLabelPosition(datum); + if (world === null) { + continue; + } + const projected = viewport.project([world[0], world[1]]); + const x = projected[0]; + const y = projected[1]; + if (x === undefined || y === undefined) { + continue; + } + if (x < -margin || y < -margin || x > maxX || y > maxY) { + continue; + } + // Anchor to the RIGHT of the dot's edge (its radius scales with zoom), left-aligned and + // vertically centred on the dot in React -- so the label reads "● Name" and its anchor is + // the text start, which holds steady beside the dot as the camera zooms. + const labelX = x + datum.worldRadius * scale + ENTITY_LABEL_GAP_PX; + const labelWidth = Math.min( + ENTITY_LABEL_MAX_WIDTH_PX, + datum.text.length * ENTITY_LABEL_APPROX_CHAR_WIDTH_PX + 14, + ); + const rect = { + left: labelX - ENTITY_LABEL_COLLISION_PADDING_PX, + right: labelX + labelWidth + ENTITY_LABEL_COLLISION_PADDING_PX, + top: y - ENTITY_LABEL_HEIGHT_PX / 2 - ENTITY_LABEL_COLLISION_PADDING_PX, + bottom: + y + ENTITY_LABEL_HEIGHT_PX / 2 + ENTITY_LABEL_COLLISION_PADDING_PX, + }; + if ( + occupied.some( + (other) => + rect.left < other.right && + rect.right > other.left && + rect.top < other.bottom && + rect.bottom > other.top, + ) + ) { + continue; + } + occupied.push(rect); + labels.push({ entityId: datum.entityId, text: datum.text, x: labelX, y }); + } + onLabels(labels); + } + + /** + * Recompute WHICH dots label + their text. PERF-CRITICAL: this is the only O(dots) scan and + * the only place {@link SceneCallbacks.resolveEntityLabel} runs, and it fires ONLY on a zoom + * or structure change -- NEVER on a pan or a position frame (those reuse the cached set and + * just re-read positions). A dot labels once its on-screen diameter clears {@link + * ENTITY_LABEL_MIN_SCREEN_DIAMETER}. Hierarchical leaves intentionally do not get always-on + * hub labels; the bubble and edge labels already carry that view's orientation. + */ + #rebuildEntityLabelData(): void { + const resolveLabel = this.#callbacks.resolveEntityLabel; + const structure = this.#handle.getStructure(); + if (resolveLabel === undefined || structure === undefined) { + this.#entityLabelData = []; + return; + } + const scale = 2 ** viewStateZoom(this.#viewState); + const data: EntityLabelDatum[] = []; + const push = ( + layoutId: ClusterId, + recordIndex: number, + worldRadius: number, + ): void => { + const entityId = this.#handle.resolveEntityId(layoutId, recordIndex); + if (entityId === undefined) { + return; + } + const text = resolveLabel(entityId); + if (text !== undefined && text.length > 0) { + data.push({ layoutId, recordIndex, entityId, text, worldRadius }); + } + }; + + // Flat tier: one whole-graph SAB. Each record carries its by-degree radius (the worker's + // connectivity authority). A dot is a hub candidate when that radius marks it as connected + // enough AND it is large enough on screen; of the candidates, only the largest few are kept so + // the labels orient the view. (Ranking by radius, not a main-thread degree tally, is what lets + // a node enlarged by frontier expansion still read as a hub.) + const flatGraph = structure.flatGraph; + if (flatGraph !== undefined) { + const cluster = this.#handle.getClusters().get(flatGraph.layoutId); + if (cluster !== undefined) { + const floats = new Float32Array(cluster.versionView.buffer); + const candidates: { index: number; radius: number }[] = []; + for (let index = 0; index < flatGraph.count; index++) { + const recordBase = + (FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES) / 4; + const radius = floats[recordBase + FLAT_RADIUS_BYTE_OFFSET / 4] ?? 0; + if ( + radius >= HUB_LABEL_MIN_RADIUS && + radius * 2 * scale > ENTITY_LABEL_MIN_SCREEN_DIAMETER + ) { + candidates.push({ index, radius }); + } + } + candidates.sort((lhs, rhs) => rhs.radius - lhs.radius); + for (const candidate of candidates.slice(0, HUB_LABEL_MAX_COUNT)) { + push(flatGraph.layoutId, candidate.index, candidate.radius); + } + } + } + + this.#entityLabelData = data; + } + + /** + * Recompute the per-render-index type-icon atlas KEYS for BOTH tiers (the flat whole-graph SAB + * into {@link #entityIconNames}, and each open leaf's SAB into {@link #leafIconNames}), and ensure + * those icons are rasterised. PERF-CRITICAL and gated EXACTLY like {@link #rebuildEntityLabelData}: + * this is the only O(dots) icon-resolution scan and the only place + * {@link SceneCallbacks.resolveEntityIcon} runs -- it fires ONLY on a structure / resolver change, + * NEVER on a pan or a position frame (those reuse the cached `names` and just re-read SAB + * positions). Every dot gets an entry (its key or null); soft-LOD sizing in the IconLayer hides + * small ones, so there is no zoom gate here (which is also why, unlike labels, a zoom change need + * not rebuild this). + */ + #rebuildEntityIconData(): void { + const resolveIcon = this.#callbacks.resolveEntityIcon; + const structure = this.#handle.getStructure(); + if (resolveIcon === undefined || structure === undefined) { + this.#entityIconNames = []; + this.#leafIconNames = new Map(); + this.#entityIconNamesVersion += 1; + return; + } + + const keys = new Set(); + // Resolve every record of a layout SAB to its icon key (or null), index-aligned with the dots. + const scanLayout = ( + layoutId: ClusterId, + count: number, + ): (string | null)[] => { + const names = Array.from({ length: count }).fill(null); + for (let index = 0; index < count; index++) { + const entityId = this.#handle.resolveEntityId(layoutId, index); + if (entityId === undefined) { + continue; + } + const key = resolveIcon(entityId); + if (key !== null && key.length > 0) { + names[index] = key; + keys.add(key); + } + } + return names; + }; + + // Flat tier: one whole-graph SAB. + const flatGraph = structure.flatGraph; + this.#entityIconNames = + flatGraph !== undefined && + this.#handle.getClusters().get(flatGraph.layoutId) !== undefined + ? scanLayout(flatGraph.layoutId, flatGraph.count) + : []; + + // Hierarchical tier: one SAB per open leaf. + const leafNames = new Map(); + for (const layer of structure.entityLayers) { + if (this.#handle.getClusters().get(layer.layoutId) !== undefined) { + leafNames.set(layer.layoutId, scanLayout(layer.layoutId, layer.count)); + } + } + this.#leafIconNames = leafNames; + + this.#entityIconNamesVersion += 1; + // Rasterise any not-yet-known icons; emoji land synchronously, URLs resolve async and bump the + // atlas version + re-push on load (so a still-loading icon is simply absent, then appears). + this.#iconAtlas.ensureIcons([...keys]); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts new file mode 100644 index 00000000000..c44c52771b1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts @@ -0,0 +1,147 @@ +/** + * The selected entity dot: a ring drawn over it, tracked by the buffer + render index the + * pick resolved to so its position is read LIVE from the same SAB the dots use. The ring + * therefore rides a settling layout (via the position tick) and pan/zoom (it is world-space) + * with no rebuild round-trip. The Scene owns the selection state + gestures; this module + * owns the geometry read and the layer. + */ +import { ScatterplotLayer } from "@deck.gl/layers"; + +import { graphColors } from "../visual-style"; +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, + leafNodeX, + leafNodeY, +} from "../worker/buffers/position-buffer"; + +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { ClusterId } from "../ids"; +import type { ClusterReference } from "./worker-connection"; +import type { EntityId } from "@blockprotocol/type-system"; +import type { Layer } from "@deck.gl/core"; + +/** A selected entity dot, tracked by the buffer + index the pick resolved to. */ +export interface Selection { + readonly entityId: EntityId; + readonly layoutId: ClusterId; + /** + * Render index into the layout's buffer. Stable for a hierarchical leaf (fixed node set); + * for the flat buffer it is the live record index, valid until the buffer reorders -- the + * Scene drops the selection when a structure frame shows the index no longer resolves to + * the same entity. + */ + readonly localIndex: number; +} + +/** World position + radius of a node, read live from its SAB / structure. */ +export interface SelectionGeometry { + readonly x: number; + readonly y: number; + readonly radius: number; +} + +/** + * The selected node's current world position + radius, or null if its layout/record is gone. + * The flat buffer is interleaved world-space records; a hierarchical leaf is positions-only + * and LOCAL to its leaf origin (added back here from the cluster positions frame). + */ +export function nodeGeometry( + layoutId: ClusterId, + localIndex: number, + cluster: ClusterReference, + structure: StructureFrame, + positions: PositionsFrame, +): SelectionGeometry | null { + if (cluster.flatCapacity !== undefined) { + const floats = new Float32Array(cluster.versionView.buffer); + const base = (FLAT_HEADER_BYTES + localIndex * FLAT_RECORD_BYTES) / 4; + const x = floats[base]; + const y = floats[base + 1]; + if (x === undefined || y === undefined) { + return null; + } + return { x, y, radius: floats[base + FLAT_RADIUS_BYTE_OFFSET / 4] ?? 0 }; + } + + const layer = structure.entityLayers.find( + (entry) => entry.layoutId === layoutId, + ); + if (!layer) { + return null; + } + const originX = positions.clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = + positions.clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + return { + x: originX + leafNodeX(cluster.positions, localIndex), + y: originY + leafNodeY(cluster.positions, localIndex), + radius: layer.radius, + }; +} + +/** + * A screen-space ring over each selected geometry. The anchor is in graph world space, but the + * mark itself is pixel-sized UI so selection remains readable at every zoom without min/max clamps. + * Never pickable -- it must not eat the dot it rings. + */ +function ringLayer( + id: string, + data: readonly SelectionGeometry[], + radiusPixels: number, + lineWidth: number, + color: readonly [number, number, number, number], + filled: boolean, + positionTick: number, +): Layer { + return new ScatterplotLayer({ + id, + data, + getPosition: (datum) => [datum.x, datum.y], + getRadius: radiusPixels, + radiusUnits: "pixels", + billboard: true, + radiusScale: 1, + stroked: true, + filled, + getFillColor: color, + getLineColor: color, + lineWidthUnits: "pixels", + getLineWidth: lineWidth, + pickable: false, + updateTriggers: { getPosition: positionTick }, + }); +} + +/** + * A neutral ring on the selected node. World-space + `positionTick`-triggered so it tracks + * settling + pan/zoom; one layer, empty data when nothing is selected so the layer set stays + * stable. Ego neighbors are conveyed by the focus dim (un-dimmed dots + bubbles), not rings. + */ +export function selectionOverlayLayers( + selected: SelectionGeometry | null, + positionTick: number, +): Layer[] { + const data = selected ? [selected] : []; + return [ + ringLayer( + "selection-halo", + data, + 16, + 2, + graphColors.selectionHalo, + false, + positionTick, + ), + ringLayer( + "selection-ring", + data, + 10, + 2, + graphColors.selection, + false, + positionTick, + ), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts new file mode 100644 index 00000000000..173fc7e84d6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts @@ -0,0 +1,243 @@ +/** + * TYPE-ICON render: each entity's type icon drawn at its dot centre via a deck.gl `IconLayer`, + * reading the SAME SAB the dots read (binary attributes, stride/offset onto the record fields) -- so + * positions/sizes never drift from the dots and there is no gather. Icons come from {@link IconAtlas} + * (a rasterised atlas of emoji + URL silhouettes); `names` maps a render index to its atlas key (or + * null for none). {@link typeIconLayer} serves the flat tier (one whole-graph SAB, per-node radius); + * {@link leafTypeIconLayers} serves the hierarchical tier (one layer per open leaf, uniform radius). + * + * SOFT LOD: the icon is sized as a fraction of the dot DIAMETER and fades in only once that dot has + * enough screen presence. The only CPU zoom work is a coarse-bucket accessor refresh from Scene, not + * per-pan churn. + */ +import { IconLayer } from "@deck.gl/layers"; + +import { + FLAT_HEADER_BYTES, + FLAT_RADIUS_BYTE_OFFSET, + FLAT_RECORD_BYTES, + leafPositionAttribute, +} from "../worker/buffers/position-buffer"; + +import type { + PositionsFrame, + RenderFlatGraph, + StructureFrame, +} from "../frames"; +import type { ClusterId } from "../ids"; +import type { IconAtlas } from "./gpu/icon-atlas"; +import type { ClusterReference } from "./worker-connection"; +import type { Layer } from "@deck.gl/core"; +import type { Device } from "@luma.gl/core"; + +/** + * Icon diameter as a fraction of the dot DIAMETER, leaving a ring of the dot's type colour visible + * around the icon as padding so the glyph reads as sitting INSIDE the dot, not covering it. + */ +const ICON_TO_DOT_DIAMETER = 0.55; +const ICON_MIN_SCREEN_DIAMETER = 18; +const ICON_FADE_PX = 10; + +function iconAlpha(screenDiameter: number): number { + const progress = Math.min( + 1, + Math.max( + 0, + (screenDiameter - ICON_MIN_SCREEN_DIAMETER + ICON_FADE_PX) / ICON_FADE_PX, + ), + ); + return Math.round(235 * progress); +} + +interface TypeIconLayerParams { + readonly graph: RenderFlatGraph; + readonly clusters: Map; + readonly atlas: IconAtlas; + /** The GPU device the atlas texture must be built on (the Deck instance's device). */ + readonly device: Device; + /** Per-render-index atlas key, or null for "no icon" (built by the Scene's icon-data scan). */ + readonly names: readonly (string | null)[]; + /** Version of {@link names}; combined with the atlas version drives the getIcon trigger. */ + readonly namesVersion: number; + /** Bumped every position frame; drives the position/size triggers (same as the dots). */ + readonly positionTick: number; + /** Current view zoom, quantized by Scene before this layer is rebuilt. */ + readonly zoom: number; + /** Drives icon visibility/color accessors when the coarse zoom LOD bucket changes. */ + readonly zoomBucket: number; +} + +export function typeIconLayer({ + graph, + clusters, + atlas, + device, + names, + namesVersion, + positionTick, + zoom, + zoomBucket, +}: TypeIconLayerParams): Layer[] { + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + const floats = new Float32Array(cluster.versionView.buffer); + const scale = 2 ** zoom; + const radiusAt = (index: number): number => { + const recordBase = + (FLAT_HEADER_BYTES + index * FLAT_RECORD_BYTES) / + Float32Array.BYTES_PER_ELEMENT; + return ( + floats[ + recordBase + FLAT_RADIUS_BYTE_OFFSET / Float32Array.BYTES_PER_ELEMENT + ] ?? 0 + ); + }; + return [ + new IconLayer({ + id: "flat-type-icons", + data: { + length: graph.count, + attributes: { + getPosition: { + value: floats, + size: 2, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES, + }, + // The dot RADIUS feeds getSize; sizeScale below turns radius into the icon diameter. + getSize: { + value: floats, + size: 1, + stride: FLAT_RECORD_BYTES, + offset: FLAT_HEADER_BYTES + FLAT_RADIUS_BYTE_OFFSET, + }, + }, + }, + iconAtlas: atlas.getTexture(device), + iconMapping: atlas.getMapping(), + // size = radius * 2 * ICON_TO_DOT_DIAMETER = icon diameter; in `common` units it tracks the + // node-contained dot mark rather than acting like a fixed UI glyph. + sizeUnits: "common", + sizeScale: 2 * ICON_TO_DOT_DIAMETER, + getIcon: (_: unknown, info: { index: number }) => { + const key = names[info.index]; + if (key === null || key === undefined || !atlas.has(key)) { + return ""; + } + return iconAlpha(radiusAt(info.index) * 2 * scale) > 0 ? key : ""; + }, + // Cells are pre-coloured (white silhouettes / full-colour emoji): draw them as-is. + getColor: (_: unknown, info: { index: number }) => [ + 255, + 255, + 255, + iconAlpha(radiusAt(info.index) * 2 * scale), + ], + billboard: true, + pickable: false, + updateTriggers: { + getPosition: positionTick, + getSize: positionTick, + // A names change OR a newly-ready async raster (atlas.version) must re-evaluate icons. + getIcon: `${namesVersion}:${atlas.version}:${zoomBucket}`, + getColor: zoomBucket, + }, + }), + ]; +} + +interface LeafTypeIconLayersParams { + readonly structure: StructureFrame; + readonly positions: PositionsFrame; + readonly clusters: Map; + readonly atlas: IconAtlas; + /** The GPU device the atlas texture must be built on (the Deck instance's device). */ + readonly device: Device; + /** + * Per open leaf (keyed by `layoutId`), the per-local-index atlas key (or null for none), built by + * the Scene's icon-data scan -- index-aligned with the leaf's SAB records / dots. + */ + readonly namesByLeaf: ReadonlyMap; + /** Version of {@link namesByLeaf}; with the atlas version drives the getIcon trigger. */ + readonly namesVersion: number; + /** Bumped every position frame; drives the position trigger (same as the dots). */ + readonly positionTick: number; + /** Current view zoom, quantized by Scene before this layer is rebuilt. */ + readonly zoom: number; + /** Drives icon visibility/color accessors when the coarse zoom LOD bucket changes. */ + readonly zoomBucket: number; +} + +/** + * Hierarchical-tier type icons: one {@link IconLayer} per open leaf, drawn over the leaf's entity + * dots. Each leaf reads its own position SAB (local coords) offset to the leaf centre by the SAME + * modelMatrix the dots use, so the icons track the dots exactly. A leaf's dots share ONE radius, so + * the soft-LOD is all-or-nothing per leaf: below the screen-size bar the leaf's layer is skipped + * entirely (cheaper than a per-dot fade that would never differ within a leaf). + */ +export function leafTypeIconLayers({ + structure, + positions, + clusters, + atlas, + device, + namesByLeaf, + namesVersion, + positionTick, + zoom, + zoomBucket, +}: LeafTypeIconLayersParams): Layer[] { + const scale = 2 ** zoom; + const clusterPositions = positions.clusterPositions; + const layers: Layer[] = []; + for (const layer of structure.entityLayers) { + const cluster = clusters.get(layer.layoutId); + const names = namesByLeaf.get(layer.layoutId); + if (!cluster || !names) { + continue; + } + // Uniform leaf-dot radius -> one screen diameter for the whole leaf, so the fade is all-or-none. + const alpha = iconAlpha(layer.radius * 2 * scale); + if (alpha <= 0) { + continue; + } + const originX = clusterPositions[layer.leafClusterIndex * 2] ?? 0; + const originY = clusterPositions[layer.leafClusterIndex * 2 + 1] ?? 0; + layers.push( + new IconLayer({ + id: `leaf-type-icons:${layer.layoutId}`, + data: { + length: layer.count, + attributes: { + getPosition: leafPositionAttribute(cluster.versionView.buffer), + }, + }, + // oxfmt-ignore + modelMatrix: [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + originX, originY, 0, 1, + ], + iconAtlas: atlas.getTexture(device), + iconMapping: atlas.getMapping(), + sizeUnits: "common", + getSize: layer.radius * 2 * ICON_TO_DOT_DIAMETER, + getIcon: (_: unknown, info: { index: number }) => { + const key = names[info.index]; + return key !== null && key !== undefined && atlas.has(key) ? key : ""; + }, + getColor: [255, 255, 255, alpha], + billboard: true, + pickable: false, + updateTriggers: { + getPosition: positionTick, + getIcon: `${namesVersion}:${atlas.version}:${zoomBucket}`, + }, + }), + ); + } + return layers; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts new file mode 100644 index 00000000000..e43a7ac704f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts @@ -0,0 +1,70 @@ +/** + * Thin React lifecycle wrapper around {@link WorkerConnection}: creates the + * connection on mount, surfaces the only two pieces of state the tree re-renders on + * (`ready` for the ingest gate, `error`), and feeds it new type schemas. All per-frame + * data flows through the connection's subscribe stream, never React state. + */ +import { useEffect, useState } from "react"; + +import { defaultVizConfig } from "../config"; +import { WorkerConnection } from "./worker-connection"; + +import type { VizConfig } from "../config"; +import type { PropertySchemaEntry, TypeSchemaEntry } from "../worker/protocol"; +import type { WorkerHandle } from "./worker-connection"; + +interface UseGraphWorkerOptions { + readonly config?: VizConfig; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; + /** + * Tears down and recreates the worker whenever this value changes. The worker's ingest is + * additive, so it has no way to retract entities; the caller changes this when the entity set is + * REPLACED (its data source changed), not merely extended, to start from a clean slate. + */ + readonly resetKey?: string | number; +} + +interface UseGraphWorkerResult { + /** Undefined until the connection is created in the mount effect (client only). */ + readonly handle: WorkerHandle | undefined; + readonly ready: boolean; + readonly error: string | undefined; +} + +export function useGraphWorker({ + config = defaultVizConfig, + typeSchemas, + propertySchemas, + resetKey, +}: UseGraphWorkerOptions): UseGraphWorkerResult { + const [connection, setConnection] = useState( + undefined, + ); + const [ready, setReady] = useState(false); + const [error, setError] = useState(undefined); + + useEffect(() => { + const created = new WorkerConnection({ + config, + onReady: () => setReady(true), + onError: setError, + }); + setConnection(created); + return () => { + created.dispose(); + setConnection(undefined); + setReady(false); + setError(undefined); + }; + }, [config, resetKey]); + + // Send type + property schemas once the worker is ready and whenever they change. + useEffect(() => { + if (connection && ready && typeSchemas.length > 0) { + connection.registerTypes(typeSchemas, propertySchemas); + } + }, [connection, ready, typeSchemas, propertySchemas]); + + return { handle: connection, ready, error }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts new file mode 100644 index 00000000000..e14f8a88a6f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts @@ -0,0 +1,661 @@ +/** + * Owns the graph worker connection and turns its messages into a coalesced + * subscribe stream, split by update rate: + * - structure events (topology / LOD cut) fire when a new StructureFrame commits; + * - position events (cluster positions, edge geometry, entity SAB notifies) are + * coalesced to at most one per animation frame. + * + * The presentation subscribes and drives Deck.gl imperatively from these events, so + * no React state ever holds per-frame data and the layer set is never rebuilt wholesale. + */ +import { ClusterId, type EntityIdx } from "../ids"; +import { + FLAT_ENTITYIDX_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, +} from "../worker/buffers/position-buffer"; +import { decodeEntityId, ID_HEADER_BYTES } from "../worker/entity-id-codec"; + +import type { VizConfig } from "../config"; +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { + EgoTarget, + IngestEntity, + MainToWorkerMessage, + PropertySchemaEntry, + TypeSchemaEntry, + WorkerToMainMessage, +} from "../worker/protocol"; +import type { EntityId } from "@blockprotocol/type-system"; + +const WORKER_DEBUG_LOGS_KEY = "hashGraphDebugWorkerLogs"; + +function workerDebugLogsEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + return window.localStorage.getItem(WORKER_DEBUG_LOGS_KEY) === "1"; +} + +/** A view into one open leaf's entity-position SharedArrayBuffer. */ +export interface ClusterReference { + readonly clusterId: ClusterId; + /** Int32 version counter at byte 0; written + Atomics.notify'd by the worker. */ + readonly versionView: Int32Array; + /** `[x0, y0, x1, y1, ...]` in the leaf's LOCAL frame. Replaced on non-SAB fallback. */ + positions: Float32Array; + readonly nodeIds: readonly string[]; + /** + * Present when this is the flat-tier interleaved `FlatGraphBuffer` (positions + + * radii + colours in one buffer, capacity = this). The presentation reads the + * record fields straight off `versionView.buffer` via stride/offset; absent for + * a positions-only entity SAB. + */ + readonly flatCapacity?: number; +} + +export interface ViewportRect { + readonly zoom: number; + readonly center: readonly [number, number]; + readonly width: number; + readonly height: number; +} + +export type WorkerEvent = + | { readonly kind: "structure"; readonly frame: StructureFrame } + | { + readonly kind: "position"; + readonly frame: PositionsFrame; + /** + * Layout ids whose backing buffer was (re)allocated in this flush, so a + * subscriber holding views over the old buffer rebinds them. + */ + readonly replacedBuffers: readonly ClusterId[]; + }; + +export type WorkerListener = (event: WorkerEvent) => void; + +/** The public surface the presentation drives Deck.gl from. */ +export interface WorkerHandle { + /** Latest topology, or undefined before the first structure frame. */ + getStructure(): StructureFrame | undefined; + /** Latest cluster positions + edge geometry. */ + getPositions(): PositionsFrame | undefined; + /** Open-leaf entity position SABs, keyed by leaf cluster id. */ + getClusters(): Map; + /** + * Subscribe to coalesced structure/position updates. Replays the current + * structure + position immediately so a late subscriber catches up. Returns an + * unsubscribe function. + */ + subscribe(listener: WorkerListener): () => void; + /** + * Resolve a picked entity dot to its EntityId via the EntityIdx->EntityId map SAB. The + * flat tier reads the stable `entityIdx` off the (reorderable) record; a hierarchical + * leaf (fixed node set) takes it from the leaf's nodeIds. Undefined until the id-map and + * the layout's buffer both exist. + */ + resolveEntityId( + layoutId: ClusterId, + recordIndex: number, + ): EntityId | undefined; + /** Decode an EntityIdx straight to its EntityId via the id-map SAB -- a picked edge already + * has the link's EntityIdx, so it skips the record read {@link resolveEntityId} does. */ + entityIdToId(entityIdx: EntityIdx): EntityId | undefined; + /** + * The raw EntityIdx (join key) for a record -- the integer {@link resolveEntityId} decodes. + * Used to query a node's neighbors without re-deriving the buffer layout in the caller. + */ + entityIdxAt(layoutId: ClusterId, recordIndex: number): EntityIdx | undefined; + /** + * Locate the wanted entityIdxs within a layout's buffer -> their current render indices, for + * placing highlight neighbors. A hierarchical leaf maps via its (fixed) nodeIds; the flat + * buffer reorders, so its live records are scanned. + */ + locateRecords( + layoutId: ClusterId, + wanted: ReadonlySet, + ): Map; + /** Ask the worker for a selected node's ego (its neighbors' visible representatives). */ + queryEgo(entityIdx: EntityIdx): Promise; + /** Ask the worker for the link entities aggregated by a highway lane (its `laneId`). */ + queryHighwayLinks(laneId: number): Promise; + /** Pin a hierarchical leaf open (with ancestors) regardless of zoom, or null to clear. */ + setPinned(clusterId: ClusterId | null): void; + /** Highlight entities (selection ego now, path later); empty restores full colour. */ + setHighlight(entityIdxs: readonly EntityIdx[]): void; + ingestBatch(entities: readonly IngestEntity[]): void; + sendViewport(viewport: ViewportRect): void; + registerTypes( + typeSchemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): void; +} +interface WorkerConnectionConfig { + readonly config: VizConfig; + readonly onReady: () => void; + readonly onError: (message: string) => void; +} + +export class WorkerConnection implements WorkerHandle { + readonly #worker: Worker; + readonly #onReady: () => void; + readonly #onError: (message: string) => void; + + #structure: StructureFrame | undefined; + #positions: PositionsFrame | undefined; + /** Held until its paired positions frame lands, so the two commit together. */ + #pendingStructure: StructureFrame | undefined; + readonly #clusters = new Map(); + /** Records-region view of the EntityIdx->EntityId join map SAB. */ + #entityIdMapBytes: Uint8Array | undefined; + + readonly #listeners = new Set(); + + #batchId = 0; + #frameId = 0; + /** Correlates async ego queries (ego-highlight) with their replies. */ + #egoRequestId = 0; + readonly #egoRequests = new Map< + number, + (targets: readonly EgoTarget[]) => void + >(); + + /** Correlates async highway-links queries (opening a highway's link table) with replies. */ + #highwayRequestId = 0; + readonly #highwayRequests = new Map< + number, + (linkEntityIdxs: readonly EntityIdx[]) => void + >(); + + #structureDirty = false; + #positionDirty = false; + #replacedBuffers: ClusterId[] = []; + #flushHandle: number | undefined; + + constructor({ config, onReady, onError }: WorkerConnectionConfig) { + this.#onReady = onReady; + this.#onError = onError; + this.#worker = new Worker(new URL("../worker/entry.ts", import.meta.url)); + this.#worker.onmessage = ({ data }: MessageEvent) => + this.#handleMessage(data); + this.#worker.onerror = (event) => this.#onError(event.message); + const init: MainToWorkerMessage = { + type: "INIT", + config: { ...config, debug: config.debug ?? workerDebugLogsEnabled() }, + typeSchemas: [], + propertySchemas: [], + }; + this.#worker.postMessage(init); + } + + getStructure(): StructureFrame | undefined { + return this.#structure; + } + + getPositions(): PositionsFrame | undefined { + return this.#positions; + } + + getClusters(): Map { + return this.#clusters; + } + + subscribe(listener: WorkerListener): () => void { + this.#listeners.add(listener); + if (this.#structure) { + listener({ kind: "structure", frame: this.#structure }); + } + if (this.#positions) { + listener({ + kind: "position", + frame: this.#positions, + replacedBuffers: [], + }); + } + return () => { + this.#listeners.delete(listener); + }; + } + + resolveEntityId( + layoutId: ClusterId, + recordIndex: number, + ): EntityId | undefined { + const mapBytes = this.#entityIdMapBytes; + const cluster = this.#clusters.get(layoutId); + if (!mapBytes || !cluster || recordIndex < 0) { + return undefined; + } + + // Hierarchical leaf: a positions-only SAB with a node set fixed at creation, so the + // render index maps straight through nodeIds (each the stable entityIdx, stringified) + // to the global id-map. (The flat buffer reorders, so it can't use a creation list.) + if (cluster.flatCapacity === undefined) { + const entityIdx = cluster.nodeIds[recordIndex]; + return entityIdx === undefined + ? undefined + : decodeEntityId(mapBytes, Number(entityIdx)); + } + + // Flat-tier FlatGraphBuffer: records reorder as entities stream, so read the CURRENT + // entityIdx join key off the record itself, then decode it. + const records = new Uint32Array(cluster.versionView.buffer); + const slot = + (FLAT_HEADER_BYTES + + recordIndex * FLAT_RECORD_BYTES + + FLAT_ENTITYIDX_BYTE_OFFSET) / + 4; + const entityIdx = records[slot]; + return entityIdx === undefined + ? undefined + : decodeEntityId(mapBytes, entityIdx); + } + + entityIdToId(entityIdx: EntityIdx): EntityId | undefined { + const mapBytes = this.#entityIdMapBytes; + return mapBytes === undefined + ? undefined + : decodeEntityId(mapBytes, entityIdx); + } + + entityIdxAt(layoutId: ClusterId, recordIndex: number): EntityIdx | undefined { + const cluster = this.#clusters.get(layoutId); + if (!cluster || recordIndex < 0) { + return undefined; + } + // Hierarchical leaf: nodeIds[recordIndex] IS the entityIdx (stringified). Flat: read it + // off the record -- the same join key resolveEntityId decodes. + if (cluster.flatCapacity === undefined) { + const idx = cluster.nodeIds[recordIndex]; + return idx === undefined ? undefined : (Number(idx) as EntityIdx); + } + const records = new Uint32Array(cluster.versionView.buffer); + const slot = + (FLAT_HEADER_BYTES + + recordIndex * FLAT_RECORD_BYTES + + FLAT_ENTITYIDX_BYTE_OFFSET) / + 4; + return records[slot] as EntityIdx | undefined; + } + + locateRecords( + layoutId: ClusterId, + wanted: ReadonlySet, + ): Map { + const result = new Map(); + const cluster = this.#clusters.get(layoutId); + if (!cluster || wanted.size === 0) { + return result; + } + if (cluster.flatCapacity === undefined) { + // Hierarchical leaf: fixed node set, nodeIds[index] is the entityIdx (stringified). + for (let index = 0; index < cluster.nodeIds.length; index++) { + const raw = cluster.nodeIds[index]; + if (raw === undefined) { + continue; + } + const entityIdx = Number(raw) as EntityIdx; + if (wanted.has(entityIdx)) { + result.set(entityIdx, index); + } + } + return result; + } + // Flat buffer: records reorder, so scan the live records for the wanted entityIdxs. + const records = new Uint32Array(cluster.versionView.buffer); + const count = records[1] ?? 0; + for (let index = 0; index < count; index++) { + const entityIdx = records[ + (FLAT_HEADER_BYTES + + index * FLAT_RECORD_BYTES + + FLAT_ENTITYIDX_BYTE_OFFSET) / + 4 + ] as EntityIdx | undefined; + if (entityIdx !== undefined && wanted.has(entityIdx)) { + result.set(entityIdx, index); + } + } + return result; + } + + queryEgo(entityIdx: EntityIdx): Promise { + this.#egoRequestId += 1; + const requestId = this.#egoRequestId; + const message: MainToWorkerMessage = { + type: "QUERY_EGO", + requestId, + entityIdx, + }; + this.#worker.postMessage(message); + return new Promise((resolve) => { + this.#egoRequests.set(requestId, resolve); + }); + } + + queryHighwayLinks(laneId: number): Promise { + this.#highwayRequestId += 1; + const requestId = this.#highwayRequestId; + const message: MainToWorkerMessage = { + type: "QUERY_HIGHWAY_LINKS", + requestId, + laneId, + }; + this.#worker.postMessage(message); + return new Promise((resolve) => { + this.#highwayRequests.set(requestId, resolve); + }); + } + + setPinned(clusterId: ClusterId | null): void { + const message: MainToWorkerMessage = { type: "SET_PINNED", clusterId }; + this.#worker.postMessage(message); + } + + setHighlight(entityIdxs: readonly EntityIdx[]): void { + const message: MainToWorkerMessage = { + type: "SET_HIGHLIGHT", + entityIdxs, + }; + this.#worker.postMessage(message); + } + + ingestBatch(entities: readonly IngestEntity[]): void { + this.#batchId += 1; + const message: MainToWorkerMessage = { + type: "INGEST_BATCH", + batchId: `batch-${this.#batchId}`, + entities, + }; + this.#worker.postMessage(message); + } + + sendViewport({ zoom, center, width, height }: ViewportRect): void { + this.#frameId += 1; + const message: MainToWorkerMessage = { + type: "VIEWPORT_CHANGED", + frameId: `vp-${this.#frameId}`, + zoom, + center, + width, + height, + }; + this.#worker.postMessage(message); + } + + registerTypes( + typeSchemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): void { + const message: MainToWorkerMessage = { + type: "REGISTER_TYPES", + typeSchemas, + propertySchemas, + }; + this.#worker.postMessage(message); + } + + /** Tear down the worker and watchers; called by the hook on unmount. */ + dispose(): void { + if (this.#flushHandle !== undefined) { + cancelAnimationFrame(this.#flushHandle); + this.#flushHandle = undefined; + } + this.#worker.terminate(); + this.#listeners.clear(); + this.#clusters.clear(); + // Resolve any in-flight ego queries so awaiters don't hang after teardown. + for (const resolve of this.#egoRequests.values()) { + resolve([]); + } + this.#egoRequests.clear(); + for (const resolve of this.#highwayRequests.values()) { + resolve([]); + } + this.#highwayRequests.clear(); + this.#structure = undefined; + this.#positions = undefined; + } + + #handleMessage(data: WorkerToMainMessage): void { + switch (data.type) { + case "READY": + this.#onReady(); + break; + case "STRUCTURE_FRAME": + // Hold; commit with the paired positions frame so the view never sees new + // clusters against stale positions. + this.#pendingStructure = data.frame; + break; + case "POSITIONS_FRAME": + this.#positions = data.frame; + if (this.#pendingStructure) { + this.#structure = this.#pendingStructure; + this.#pendingStructure = undefined; + this.#structureDirty = true; + } + this.#positionDirty = true; + this.#scheduleFlush(); + break; + case "ERROR": + this.#onError(data.message); + break; + case "EMBEDDING_CLUSTERING_NEEDED": + void this.#fetchEmbeddingClusters( + data.clusterId, + data.entityIds as string[], + data.clusterCount, + ); + break; + case "LAYOUT_CREATED": + this.#adoptClusterBuffer({ + clusterId: data.clusterId, + buffer: data.buffer, + nodeIds: data.nodeIds, + flatCapacity: data.flatCapacity, + }); + break; + case "BUFFER_REPUBLISHED": { + // A held SAB outgrew its in-place ceiling and was re-allocated; the bytes + // were copied across, so keep the prior nodeIds and swap to the new buffer. + const prev = this.#clusters.get(data.target.clusterId); + if (prev) { + this.#adoptClusterBuffer({ + clusterId: data.target.clusterId, + buffer: data.buffer, + nodeIds: prev.nodeIds, + flatCapacity: data.capacity, + }); + } + break; + } + case "LAYOUT_POSITIONS": { + const cluster = this.#clusters.get(data.clusterId); + if (cluster) { + cluster.positions = data.positions; + this.#positionDirty = true; + this.#scheduleFlush(); + } + break; + } + case "LAYOUT_DESTROYED": + this.#clusters.delete(data.clusterId); + this.#positionDirty = true; + this.#scheduleFlush(); + break; + case "ENTITY_ID_MAP": + // A fresh length-tracking view covers any in-place growth that follows. + this.#entityIdMapBytes = new Uint8Array(data.buffer, ID_HEADER_BYTES); + break; + case "MODE_CHANGED": + // Mode is carried on the structure frame; nothing to do here. + break; + case "EGO_RESULT": { + const resolve = this.#egoRequests.get(data.requestId); + if (resolve) { + this.#egoRequests.delete(data.requestId); + resolve(data.targets); + } + break; + } + case "HIGHWAY_LINKS_RESULT": { + const resolve = this.#highwayRequests.get(data.requestId); + if (resolve) { + this.#highwayRequests.delete(data.requestId); + resolve(data.linkEntityIdxs); + } + break; + } + } + } + + #adoptClusterBuffer({ + clusterId, + buffer, + nodeIds, + flatCapacity, + }: { + readonly clusterId: ClusterId; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + readonly nodeIds: readonly string[]; + readonly flatCapacity: number | undefined; + }): void { + const cluster: ClusterReference = { + clusterId, + versionView: new Int32Array(buffer, 0, 1), + positions: new Float32Array(buffer, 4), + nodeIds, + flatCapacity, + }; + this.#clusters.set(clusterId, cluster); + this.#replacedBuffers.push(clusterId); + this.#positionDirty = true; + this.#scheduleFlush(); + this.#watchClusterBuffer(cluster, buffer); + } + + // Re-arm an async wait on the cluster's version word; on each notify mark a + // position flush. Self-cancels once the cluster is replaced or destroyed. + #watchClusterBuffer( + cluster: ClusterReference, + buffer: SharedArrayBuffer | ArrayBuffer, + ): void { + if ( + typeof SharedArrayBuffer === "undefined" || + !(buffer instanceof SharedArrayBuffer) || + typeof Atomics.waitAsync !== "function" + ) { + return; + } + const arm = (currentVersion: number): void => { + if (this.#clusters.get(cluster.clusterId) !== cluster) { + return; + } + const result = Atomics.waitAsync(cluster.versionView, 0, currentVersion); + const onChanged = (): void => { + if (this.#clusters.get(cluster.clusterId) !== cluster) { + return; + } + this.#positionDirty = true; + this.#scheduleFlush(); + arm(Atomics.load(cluster.versionView, 0)); + }; + if (result.async) { + void result.value.then((status) => { + if (status === "ok") { + onChanged(); + } + }); + } else { + // Version changed between load and wait ("not-equal"): re-arm immediately. + onChanged(); + } + }; + arm(Atomics.load(cluster.versionView, 0)); + } + + #scheduleFlush(): void { + if (this.#flushHandle !== undefined) { + return; + } + this.#flushHandle = requestAnimationFrame(() => { + this.#flushHandle = undefined; + this.#flush(); + }); + } + + #flush(): void { + if (this.#structureDirty) { + this.#structureDirty = false; + const frame = this.#structure; + if (frame) { + for (const listener of this.#listeners) { + listener({ kind: "structure", frame }); + } + } + } + if (this.#positionDirty) { + this.#positionDirty = false; + const frame = this.#positions; + const replacedBuffers = this.#replacedBuffers; + this.#replacedBuffers = []; + if (frame) { + for (const listener of this.#listeners) { + listener({ kind: "position", frame, replacedBuffers }); + } + } + } + } + + async #fetchEmbeddingClusters( + clusterId: string, + entityIds: string[], + clusterCount: number, + ): Promise { + try { + const apiOrigin = + process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:5001"; + const response = await fetch( + `${apiOrigin}/entities/embeddings/clusters`, + { + method: "POST", + // Send the Kratos session cookie so hash-api resolves the authenticated + // actor; without this the request is treated as the public actor and the + // graph filters out every entity the user is allowed to view. + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entityIds, clusterCount, dimension: 256 }), + }, + ); + + if (!response.ok) { + // eslint-disable-next-line no-console + console.warn( + `[embedding] clustering failed for ${clusterId}: ${response.status}`, + ); + return; + } + + const result = (await response.json()) as { + clusters: { + clusterId: number; + entityIds: string[]; + }[]; + missingEmbeddings: string[]; + }; + + const message: MainToWorkerMessage = { + type: "EMBEDDING_CLUSTERING_RESULT", + clusterId: ClusterId(clusterId), + clusters: result.clusters, + }; + this.#worker.postMessage(message); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + `[embedding] clustering request failed for ${clusterId}:`, + err, + ); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts new file mode 100644 index 00000000000..1a2d17cd8b7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts @@ -0,0 +1,78 @@ +import type { Color } from "./frames"; + +export const graphCanvasBackground = "#F7FAFC"; + +/** + * Named graph mark colours. These are presentation semantics, not a generic palette: + * - frontier / fallback recede behind query-root entities, + * - grouping marks stay translucent so topology remains legible, + * - selection is the only saturated product-blue state, + * - edge support colours are neutral so typed edge hues carry the data. + */ +export const graphColors = { + frontier: [116, 130, 148, 150], + frontierHalo: [72, 136, 216, 92], + fallbackEntity: [126, 142, 160, 220], + collapsedEdge: [112, 126, 143, 180], + fanOutEdge: [128, 142, 158, 88], + selection: [7, 117, 227, 255], + selectionHalo: [72, 179, 244, 44], + clusterStroke: [255, 255, 255, 58], + edgeUnderlay: [255, 255, 255, 76], + edgeLabelText: [55, 67, 79, 255], + edgeLabelBackground: [255, 255, 255, 230], +} as const satisfies Record; + +/** Compact HSL to RGB conversion for deterministic graph palettes. */ +export function hslToRgb( + hue: number, + saturation: number, + lightness: number, +): [number, number, number] { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const sextant = hue / 60; + const second = chroma * (1 - Math.abs((sextant % 2) - 1)); + const match = lightness - chroma / 2; + + let red = 0; + let green = 0; + let blue = 0; + if (sextant < 1) { + red = chroma; + green = second; + } else if (sextant < 2) { + red = second; + green = chroma; + } else if (sextant < 3) { + green = chroma; + blue = second; + } else if (sextant < 4) { + green = second; + blue = chroma; + } else if (sextant < 5) { + red = second; + blue = chroma; + } else { + red = chroma; + blue = second; + } + + return [ + Math.round((red + match) * 255), + Math.round((green + match) * 255), + Math.round((blue + match) * 255), + ]; +} + +export function colorWithAlpha( + color: readonly [number, number, number], + alpha: number, +): Color { + return [color[0], color[1], color[2], alpha]; +} + +/** Community hulls are looser than hierarchy bubbles, so they use lower saturation/alpha. */ +export function communityColorForId(id: number): Color { + const hue = ((id * 0.618033988749895) % 1) * 360; + return colorWithAlpha(hslToRgb(hue, 0.34, 0.66), 38); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json new file mode 100644 index 00000000000..1e8973dc4a6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "sessions": { + "8e52f36d-22df-4380-830f-8109a1e0fd71": { + "updatedAt": 1782671897211, + "files": { + "/Users/bmahmoud/projects/hash/hash/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx": { + "editCount": 6, + "findings": [] + }, + "/Users/bmahmoud/projects/hash/hash/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts": { + "editCount": 1, + "findings": [] + }, + "/Users/bmahmoud/projects/hash/hash/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts": { + "editCount": 2, + "findings": [] + } + } + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts new file mode 100644 index 00000000000..5dffc74b14e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts @@ -0,0 +1,78 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { EntityIdBuffer } from "./entity-id-buffer"; + +import type { DraftId, EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webIdA = "11111111-1111-4111-8111-111111111111" as WebId; +const entityUuidA = "22222222-2222-4222-8222-222222222222" as EntityUuid; +const webIdB = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" as WebId; +const entityUuidB = "12345678-90ab-4cde-8f01-234567890abc" as EntityUuid; +const draftId = "33333333-3333-4333-8333-333333333333" as DraftId; + +describe("EntityIdBuffer", () => { + it("round-trips a non-draft EntityId", () => { + const buffer = new EntityIdBuffer(4); + const id = entityIdFromComponents(webIdA, entityUuidA); + buffer.setId(2, id); + expect(buffer.readId(2)).toBe(id); + }); + + it("round-trips a draft EntityId (draftId slot used)", () => { + const buffer = new EntityIdBuffer(4); + const id = entityIdFromComponents(webIdA, entityUuidA, draftId); + buffer.setId(0, id); + expect(buffer.readId(0)).toBe(id); + }); + + it("keeps entries independent by EntityIdx", () => { + const buffer = new EntityIdBuffer(8); + const first = entityIdFromComponents(webIdA, entityUuidA); + const second = entityIdFromComponents(webIdB, entityUuidB); + buffer.setId(0, first); + buffer.setId(7, second); + expect(buffer.readId(0)).toBe(first); + expect(buffer.readId(7)).toBe(second); + }); + + it("overwriting an EntityIdx with a non-draft id clears a prior draftId", () => { + const buffer = new EntityIdBuffer(2); + buffer.setId(1, entityIdFromComponents(webIdA, entityUuidA, draftId)); + const plain = entityIdFromComponents(webIdB, entityUuidB); + buffer.setId(1, plain); + expect(buffer.readId(1)).toBe(plain); + }); + + it("grows in place to fit a higher EntityIdx, preserving earlier entries", () => { + const buffer = new EntityIdBuffer(2, undefined, 8); + const first = entityIdFromComponents(webIdA, entityUuidA); + buffer.setId(0, first); + expect(buffer.capacity).toBe(2); + + buffer.ensureCapacity(6); // within maxCapacity 8 → grows in place, no re-publish + expect(buffer.capacity).toBeGreaterThanOrEqual(6); + + const later = entityIdFromComponents(webIdB, entityUuidB); + buffer.setId(5, later); // beyond the original capacity, within the grown one + expect(buffer.readId(5)).toBe(later); + expect(buffer.readId(0)).toBe(first); // earlier entry survived the grow + }); + + it("re-allocates + re-publishes past maxCapacity, copying existing entries", () => { + let republished: SharedArrayBuffer | ArrayBuffer | null = null; + const onRepublish = (raw: SharedArrayBuffer | ArrayBuffer) => { + republished = raw; + }; + const buffer = new EntityIdBuffer(2, onRepublish, 4); + const first = entityIdFromComponents(webIdA, entityUuidA); + buffer.setId(0, first); + + buffer.ensureCapacity(10); // past maxCapacity 4 → re-allocate + re-publish + expect(buffer.capacity).toBeGreaterThanOrEqual(10); + expect(republished).toBe(buffer.raw); // the new buffer reached the publisher + expect(buffer.readId(0)).toBe(first); // survived the re-allocation copy + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts new file mode 100644 index 00000000000..dcd87b31ed2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts @@ -0,0 +1,53 @@ +/** + * SharedArrayBuffer-backed `EntityIdx -> EntityId` map: the join key that lets the + * main thread turn a rendered record back into its entity (for labels, icons, tooltips, + * picking) without shuffling per-entity data through the worker. + * + * The worker is the sole writer (it owns interning); the main thread is a reader. It is + * synchronized by the same atomic version bump as the position buffers, with no message, + * no library, no bidirectional sync. The byte layout lives in {@link "../entity-id-codec"} + * so the main-thread reader shares one decoder; this class is just the growable store. + */ +import { + ENTITY_ID_BYTES, + ID_HEADER_BYTES, + decodeEntityId, + encodeEntityId, +} from "../entity-id-codec"; +import { GrowableBuffer, type RepublishHandler } from "./growable-buffer"; + +import type { EntityId } from "@blockprotocol/type-system"; + +/** Default growable ceiling (entities): reserves address space, commits as it grows. + * A re-publish only fires past this. */ +const ENTITY_ID_MAX_CAPACITY = 262_144; + +export class EntityIdBuffer extends GrowableBuffer { + #bytes!: Uint8Array; + + constructor( + capacity: number, + republish?: RepublishHandler, + maxCapacity: number = ENTITY_ID_MAX_CAPACITY, + ) { + super(ID_HEADER_BYTES, ENTITY_ID_BYTES, capacity, maxCapacity, republish); + this.bindRecordViews(this.raw); + } + + protected override bindRecordViews( + raw: SharedArrayBuffer | ArrayBuffer, + ): void { + this.#bytes = new Uint8Array(raw, ID_HEADER_BYTES); + } + + /** Worker side: record the EntityId for an EntityIdx. */ + setId(entityIdx: number, entityId: EntityId): void { + encodeEntityId(this.#bytes, entityIdx, entityId); + } + + /** Reconstruct the EntityId for an EntityIdx (exercised by the round-trip tests; the + * main thread decodes its received buffer with {@link decodeEntityId} directly). */ + readId(entityIdx: number): EntityId { + return decodeEntityId(this.#bytes, entityIdx); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts new file mode 100644 index 00000000000..3e57cd92aa5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts @@ -0,0 +1,189 @@ +/* eslint-disable no-bitwise */ +/** + * Growable SharedArrayBuffer primitives + the {@link GrowableBuffer} base shared by + * every SharedArrayBuffer-backed store that streams and grows ({@link FlatGraphBuffer} in + * position-buffer.ts, the EntityId map in entity-id-buffer.ts). + * + * What those stores have in common is not their record layout, it's the grow-or- + * republish dance: try to extend the buffer in place (ES2024 growable SharedArrayBuffer) + * and, when that's impossible (growable shared buffers unsupported, or the `maxByteLength` + * ceiling hit), re-allocate a larger buffer, copy the bytes across, re-bind the + * typed-array views, and tell the main thread to swap to the new buffer. Hand-rolling that + * per store is how the subtle bits (which views are length-tracking, when a copy is + * needed, when a message must fire) drift apart. {@link GrowableBuffer} owns it exactly + * once; a subclass only describes its record layout and re-binds its own views. + */ + +/** SharedArrayBuffer is available at all (cross-origin isolation present). */ +export const sharedBufferAvailable = typeof SharedArrayBuffer !== "undefined"; + +/** + * Whether SharedArrayBuffers can grow in place (ES2024 growable SharedArrayBuffer). Where + * present, a buffer grows with no re-publish up to its `maxByteLength` and the main thread + * just re-reads the same (now-larger) buffer. Where absent, {@link growBuffer} declines, so + * {@link GrowableBuffer} re-allocates a bigger buffer and re-publishes it, the universal + * fallback. + */ +export const growableSharedBuffer = + sharedBufferAvailable && + typeof SharedArrayBuffer.prototype.grow === "function"; + +/** + * Allocate a buffer that can grow in place up to `maxByteLength` (a growable + * SharedArrayBuffer where supported, a plain SharedArrayBuffer where SABs exist but can't + * grow, else an ArrayBuffer). The ceiling reserves address space, not committed memory. + * + * Pass `resizable: false` for buffers whose bytes are uploaded to the GPU: WebGL's + * `bufferData`/`bufferSubData` reject views over a resizable ArrayBuffer, so those must be + * fixed-size and grow by re-allocation instead (see {@link GrowableBuffer}). + */ +export function makeGrowableBuffer( + byteLength: number, + maxByteLength: number, + resizable = true, +): SharedArrayBuffer | ArrayBuffer { + if (resizable && growableSharedBuffer) { + return new SharedArrayBuffer(byteLength, { maxByteLength }); + } + if (sharedBufferAvailable) { + return new SharedArrayBuffer(byteLength); + } + return new ArrayBuffer(byteLength); +} + +/** + * Grow `raw` in place to at least `byteLength`; returns false when it can't (growable + * shared buffers unsupported, or `byteLength` exceeds `maxByteLength`) so the caller + * re-allocates a fresh, larger buffer and re-publishes it. + */ +export function growBuffer( + raw: SharedArrayBuffer | ArrayBuffer, + byteLength: number, +): boolean { + if (byteLength <= raw.byteLength) { + return true; + } + if (!growableSharedBuffer || !(raw instanceof SharedArrayBuffer)) { + return false; + } + if (byteLength > raw.maxByteLength) { + return false; + } + raw.grow(byteLength); + return true; +} + +/** + * Invoked when a {@link GrowableBuffer} had to be re-allocated rather than grown in place. + * The main thread must swap to `raw` (now holding `capacity` records) and re-attach its + * version watcher, so the worker wires this to post a dedicated re-publish message. + * (In-place growth fires nothing: the main thread already holds the same, now-larger + * buffer, and its length-tracking views auto-extend.) + */ +export type RepublishHandler = ( + raw: SharedArrayBuffer | ArrayBuffer, + capacity: number, +) => void; + +/** Loud guard for the misconfiguration where a buffer overflows its ceiling but no + * handler was wired to re-publish the re-allocated buffer, far better than silently + * leaving the main thread reading a stale, detached buffer. */ +const throwOnUnhandledRepublish: RepublishHandler = () => { + throw new Error( + "GrowableBuffer overflowed its maxByteLength but no republish handler was provided, so the re-allocated buffer cannot reach the main thread.", + ); +}; + +/** + * SharedArrayBuffer-backed store with a `[version:int32]` header followed by fixed-size + * records, that grows on demand. Subclasses fix the header/record byte sizes and re-bind + * their own field views in {@link GrowableBuffer.bindRecordViews}; everything about + * growing, re-publishing, and the atomic version handshake lives here. + * + * `resizable: false` makes every allocation a fixed SharedArrayBuffer, required for buffers + * uploaded to the GPU (WebGL rejects views over resizable ArrayBuffers). Such a buffer + * can't `.grow` in place, so {@link ensureCapacity} always takes the re-allocate + + * re-publish path; the main thread swaps to the new (still fixed, uploadable) buffer. + */ +export abstract class GrowableBuffer { + /** The raw buffer; reassigned (and re-published) when it must be re-allocated. */ + raw: SharedArrayBuffer | ArrayBuffer; + protected readonly headerBytes: number; + protected readonly recordBytes: number; + #version: Int32Array; + readonly #republish: RepublishHandler; + readonly #resizable: boolean; + + protected constructor( + headerBytes: number, + recordBytes: number, + capacity: number, + maxCapacity: number, + republish: RepublishHandler = throwOnUnhandledRepublish, + resizable = true, + ) { + this.headerBytes = headerBytes; + this.recordBytes = recordBytes; + this.#republish = republish; + this.#resizable = resizable; + const cap = Math.max(1, capacity); + const maxCap = Math.max(cap, maxCapacity); + this.raw = makeGrowableBuffer( + headerBytes + cap * recordBytes, + headerBytes + maxCap * recordBytes, + resizable, + ); + this.#version = new Int32Array(this.raw, 0, 1); + // NB: cannot call bindRecordViews() here, subclass fields aren't initialised yet. + // The subclass constructor calls it once after super(). + } + + /** Records the buffer currently holds (grows as it does). */ + get capacity(): number { + return (this.raw.byteLength - this.headerBytes) / this.recordBytes; + } + + /** + * Ensure room for `capacity` records. A `resizable` buffer grows in place with no + * message where possible (length-tracking views auto-extend). Otherwise (a non- + * resizable (GPU) buffer, or one past its ceiling) it re-allocates a larger buffer, + * copies the bytes across, re-binds the views, and re-publishes so the main thread + * swaps to (and re-watches) the new buffer. + */ + ensureCapacity(capacity: number): void { + const needed = this.headerBytes + capacity * this.recordBytes; + if (growBuffer(this.raw, needed)) { + return; + } + // Double the ceiling so a resizable re-allocation can keep growing in place after. + const next = makeGrowableBuffer( + needed, + Math.max(needed, this.raw.byteLength * 2), + this.#resizable, + ); + new Uint8Array(next).set(new Uint8Array(this.raw)); + this.raw = next; + this.#version = new Int32Array(this.raw, 0, 1); + this.bindRecordViews(this.raw); + this.#republish(this.raw, capacity); + } + + /** Bump + notify the version counter so the main thread re-reads the buffer. */ + commit(): void { + if (sharedBufferAvailable) { + Atomics.store(this.#version, 0, (this.#version[0]! + 1) | 0); + Atomics.notify(this.#version, 0); + } else { + this.#version[0] = (this.#version[0]! + 1) | 0; + } + } + + /** + * Re-create the record-field views over `raw`. Called by the subclass constructor + * (once, after super()) and again on every re-publish. Must not be called from + * {@link GrowableBuffer}'s own constructor: the subclass's fields aren't live yet. + */ + protected abstract bindRecordViews( + raw: SharedArrayBuffer | ArrayBuffer, + ): void; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts new file mode 100644 index 00000000000..fdbecfffd43 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts @@ -0,0 +1,74 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { + FLAT_COLOR_BYTE_OFFSET, + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + FlatGraphBuffer, +} from "./position-buffer"; + +const SLOTS = FLAT_RECORD_BYTES / 4; + +describe("FlatGraphBuffer", () => { + it("round-trips position, radius, colour, and entityIdx in one record", () => { + const buffer = new FlatGraphBuffer(4); + buffer.setPosition(2, 12.5, -7.25); + buffer.setRadius(2, 9); + buffer.setColor(2, [10, 20, 30, 40]); + buffer.setEntityIdx(2, 12_345); + + const floats = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const bytes = new Uint8Array(buffer.raw, FLAT_HEADER_BYTES); + const u32 = new Uint32Array(buffer.raw, FLAT_HEADER_BYTES); + + expect(floats[2 * SLOTS]).toBeCloseTo(12.5, 3); + expect(floats[2 * SLOTS + 1]).toBeCloseTo(-7.25, 3); + expect(floats[2 * SLOTS + 2]).toBeCloseTo(9, 3); + const colorBase = 2 * FLAT_RECORD_BYTES + FLAT_COLOR_BYTE_OFFSET; + expect([ + bytes[colorBase], + bytes[colorBase + 1], + bytes[colorBase + 2], + bytes[colorBase + 3], + ]).toEqual([10, 20, 30, 40]); + expect(u32[2 * SLOTS + 4]).toBe(12_345); + }); + + it("is NON-resizable so WebGL can upload its views", () => { + // The crux of the GPU-upload constraint: a resizable ArrayBuffer's views are rejected + // by bufferSubData, so the flat buffer must be fixed-size and grow by re-allocation. + const buffer = new FlatGraphBuffer(4); + if (buffer.raw instanceof SharedArrayBuffer) { + expect(buffer.raw.growable).toBe(false); + } else { + expect(buffer.raw.resizable).toBe(false); + } + }); + + it("grows by re-allocating + re-publishing, copying existing records", () => { + let republished: SharedArrayBuffer | ArrayBuffer | null = null; + const onRepublish = (raw: SharedArrayBuffer | ArrayBuffer) => { + republished = raw; + }; + const buffer = new FlatGraphBuffer(2, onRepublish); + buffer.setPosition(0, 1, 2); + buffer.setEntityIdx(0, 7); + expect(buffer.capacity).toBe(2); + + buffer.ensureCapacity(10); // non-resizable → always re-allocate + re-publish + expect(buffer.capacity).toBeGreaterThanOrEqual(10); + expect(republished).toBe(buffer.raw); // the new buffer reached the publisher + // The re-allocated buffer is itself non-resizable (still GPU-uploadable). + if (buffer.raw instanceof SharedArrayBuffer) { + expect(buffer.raw.growable).toBe(false); + } + + buffer.setPosition(9, 3, 4); // a record only the grown buffer can hold + const floats = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const u32 = new Uint32Array(buffer.raw, FLAT_HEADER_BYTES); + expect(floats[9 * SLOTS]).toBeCloseTo(3, 3); + expect(floats[0 * SLOTS]).toBeCloseTo(1, 3); // record survived the re-allocation copy + expect(u32[0 * SLOTS + 4]).toBe(7); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts new file mode 100644 index 00000000000..b05624faa64 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts @@ -0,0 +1,272 @@ +/* eslint-disable no-bitwise */ +/** + * SharedArrayBuffer-backed position storage shared by every layout engine + * (d3-force entity layouts and the WebCola cluster layout). + * + * Layout: [version: int32 (4 bytes)] [x0, y0, x1, y1, ... : float32] + * + * The worker fills {@link PositionBuffer.positions} and calls + * {@link PositionBuffer.commit}; the version counter (bumped atomically and + * notified) lets the main thread detect changes via Atomics.waitAsync and read + * the same memory with zero copying. When SharedArrayBuffer is unavailable we + * fall back to a plain ArrayBuffer whose contents are messaged across. + * + * The growable SharedArrayBuffer primitives ({@link makeGrowableBuffer}, grow-or-republish) live in + * growable-buffer.ts; {@link FlatGraphBuffer} subclasses {@link GrowableBuffer} for them. + */ + +import { + GrowableBuffer, + type RepublishHandler, + sharedBufferAvailable, +} from "./growable-buffer"; + +export class PositionBuffer { + /** The raw buffer backing positions. SharedArrayBuffer when available. */ + readonly raw: SharedArrayBuffer | ArrayBuffer; + /** Interleaved [x0, y0, x1, y1, ...]; fill directly, then {@link commit}. */ + readonly positions: Float32Array; + readonly #version: Int32Array; + + constructor(nodeCount: number) { + const byteLength = 4 + nodeCount * 2 * 4; + this.raw = sharedBufferAvailable + ? new SharedArrayBuffer(byteLength) + : new ArrayBuffer(byteLength); + this.#version = new Int32Array(this.raw, 0, 1); + this.positions = new Float32Array(this.raw, 4); + } + + /** + * Publish the current {@link positions}: bump the version counter so the main + * thread sees the change. Atomics.store makes the write visible and + * Atomics.notify wakes any Atomics.waitAsync watcher. + */ + commit(): void { + if (sharedBufferAvailable) { + Atomics.store(this.#version, 0, (this.#version[0]! + 1) | 0); + Atomics.notify(this.#version, 0); + } else { + this.#version[0] = (this.#version[0]! + 1) | 0; + } + } +} + +/** + * Per-leaf entity-dot buffer: a 4-byte version header, then one INTERLEAVED record per node: + * + * [version:i32] then count records of { x:f32, y:f32, rgba:u8 x4 } (12 bytes each) + * + * Positions stream from the force layout each tick ({@link setPosition}); the worker writes the + * per-node colour once it knows it ({@link setColor}), so a selection focus-dim can mutate one + * node's colour in place without a rebuild. The renderer reads BOTH straight off the buffer as + * Deck binary attributes -- see {@link leafPositionAttribute} / {@link leafColorAttribute}. + * + * Distinct from the positions-only {@link PositionBuffer} (still used by the macro cluster + * layout, which carries no colour) and from {@link FlatGraphBuffer} (one whole-graph, + * growable, with radius + entityIdx too). A leaf's node set is fixed for the layout's life + * (it is recreated wholesale on a count change), so this buffer is non-growable. + */ +export const LEAF_HEADER_BYTES = 4; +export const LEAF_RECORD_BYTES = 12; +/** Byte offset of the rgba colour within a record. */ +export const LEAF_COLOR_BYTE_OFFSET = 8; +/** Slots per record (4-byte aligned, so the float and byte views share the stride). */ +const LEAF_RECORD_SLOTS = LEAF_RECORD_BYTES / 4; + +export class EntityPositionBuffer { + readonly raw: SharedArrayBuffer | ArrayBuffer; + readonly #version: Int32Array; + /** Record fields as floats: record `i` -> [i*S]=x, [i*S+1]=y (S = LEAF_RECORD_SLOTS). */ + readonly #floats: Float32Array; + /** Record fields as bytes: record `i` colour at [i*LEAF_RECORD_BYTES + LEAF_COLOR_BYTE_OFFSET ..]. */ + readonly #bytes: Uint8Array; + + constructor(nodeCount: number) { + const byteLength = LEAF_HEADER_BYTES + nodeCount * LEAF_RECORD_BYTES; + this.raw = sharedBufferAvailable + ? new SharedArrayBuffer(byteLength) + : new ArrayBuffer(byteLength); + this.#version = new Int32Array(this.raw, 0, 1); + this.#floats = new Float32Array(this.raw, LEAF_HEADER_BYTES); + this.#bytes = new Uint8Array(this.raw, LEAF_HEADER_BYTES); + } + + setPosition(index: number, x: number, y: number): void { + const base = index * LEAF_RECORD_SLOTS; + this.#floats[base] = x; + this.#floats[base + 1] = y; + } + + setColor( + index: number, + color: readonly [number, number, number, number], + ): void { + const offset = index * LEAF_RECORD_BYTES + LEAF_COLOR_BYTE_OFFSET; + this.#bytes[offset] = color[0]; + this.#bytes[offset + 1] = color[1]; + this.#bytes[offset + 2] = color[2]; + this.#bytes[offset + 3] = color[3]; + } + + commit(): void { + if (sharedBufferAvailable) { + Atomics.store(this.#version, 0, (this.#version[0]! + 1) | 0); + Atomics.notify(this.#version, 0); + } else { + this.#version[0] = (this.#version[0]! + 1) | 0; + } + } +} + +/** Local x of leaf node `index`, from a records-region view (`new Float32Array(raw, 4)`). */ +export function leafNodeX(records: Float32Array, index: number): number { + return records[index * LEAF_RECORD_SLOTS] ?? 0; +} +/** Local y of leaf node `index`, from a records-region view (`new Float32Array(raw, 4)`). */ +export function leafNodeY(records: Float32Array, index: number): number { + return records[index * LEAF_RECORD_SLOTS + 1] ?? 0; +} + +/** Deck binary `getPosition` attribute over a leaf buffer's raw bytes (interleaved, stride-3). */ +export function leafPositionAttribute(raw: SharedArrayBuffer | ArrayBuffer): { + readonly value: Float32Array; + readonly size: 2; + readonly stride: number; + readonly offset: number; +} { + return { + value: new Float32Array(raw), + size: 2, + stride: LEAF_RECORD_BYTES, + offset: LEAF_HEADER_BYTES, + }; +} + +/** Deck binary `getFillColor` attribute over a leaf buffer's raw bytes (normalized rgba). */ +export function leafColorAttribute(raw: SharedArrayBuffer | ArrayBuffer): { + readonly value: Uint8Array; + readonly size: 4; + readonly stride: number; + readonly offset: number; + readonly normalized: true; +} { + return { + value: new Uint8Array(raw), + size: 4, + stride: LEAF_RECORD_BYTES, + offset: LEAF_HEADER_BYTES + LEAF_COLOR_BYTE_OFFSET, + normalized: true, + }; +} + +/** + * Interleaved layout of the flat-tier shared buffer. All per-node GPU data lives in one + * buffer as a header plus fixed-size records: + * + * [version:i32][count:u32] then count records of + * { x:f32, y:f32, radius:f32, rgba:u8 x4, entityIdx:u32 } (20 bytes each) + * + * `entityIdx` is the join key: it maps a rendered record back to its entity (the + * main thread pairs it with the EntityIdx->EntityId map buffer, see entity-id-buffer.ts). + * + * Interleaving (one record per node) is what makes the buffer growable: appending + * a node is "write one record at index `count`, bump `count`, one atomic sync", + * no region shuffling. The renderer reads each field straight off this buffer via + * stride/offset (the constants below), so there is never a gather or a copy. + * (`version` must be the Int32Array view, Atomics.notify only accepts that.) + */ +export const FLAT_HEADER_BYTES = 8; +export const FLAT_RECORD_BYTES = 20; +/** Byte offsets of `radius` / `rgba` / `entityIdx` within a record. */ +export const FLAT_RADIUS_BYTE_OFFSET = 8; +export const FLAT_COLOR_BYTE_OFFSET = 12; +export const FLAT_ENTITYIDX_BYTE_OFFSET = 16; +/** Slots per record (4-byte aligned, so the float and u32 views share the stride). */ +const FLAT_RECORD_SLOTS = FLAT_RECORD_BYTES / 4; + +/** + * SharedArrayBuffer-backed, interleaved store for the flat-tier graph. The layout writes + * positions each tick; the worker writes radius/colour/count on commit. Per-node + * updates (a settling position, a degree-driven radius, an interaction highlight) + * are written in place, never via a structure-frame round-trip. + * + * This buffer is uploaded to the GPU each frame (the renderer reads its records as + * Deck.gl binary attributes), and WebGL rejects views over a resizable ArrayBuffer, so + * it is non-resizable (`resizable: false`). `capacity` may exceed the live `count` so + * streamed nodes append into spare records in place; outgrowing it goes through + * {@link GrowableBuffer.ensureCapacity}'s re-allocate + re-publish path (a fresh, still + * fixed, uploadable buffer), not an in-place `.grow`. + */ +export class FlatGraphBuffer extends GrowableBuffer { + /** `count` header slot. */ + #count!: Uint32Array; + /** Record fields as floats: record `i` -> [i*S]=x, [i*S+1]=y, [i*S+2]=radius + * (S = FLAT_RECORD_SLOTS). */ + #floats!: Float32Array; + /** Record fields as bytes: record `i` colour at [i*FLAT_RECORD_BYTES + 12 .. +15]. */ + #bytes!: Uint8Array; + /** Record fields as u32: record `i` entityIdx at [i*S + 4]. */ + #u32!: Uint32Array; + + constructor(capacity: number, republish?: RepublishHandler) { + // resizable: false, this buffer's bytes are uploaded to the GPU. + super( + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + capacity, + capacity, + republish, + false, + ); + this.bindRecordViews(this.raw); + } + + /** + * Re-point the field views at `raw`. Re-runs on every re-allocation (this buffer never + * grows in place, it is non-resizable for GPU upload). `#count` keeps its explicit + * length 1 (it is a single header slot). + */ + protected override bindRecordViews( + raw: SharedArrayBuffer | ArrayBuffer, + ): void { + this.#count = new Uint32Array(raw, 4, 1); + this.#floats = new Float32Array(raw, FLAT_HEADER_BYTES); + this.#bytes = new Uint8Array(raw, FLAT_HEADER_BYTES); + this.#u32 = new Uint32Array(raw, FLAT_HEADER_BYTES); + } + + get count(): number { + return this.#count[0]!; + } + + setCount(value: number): void { + this.#count[0] = value; + } + + setPosition(index: number, x: number, y: number): void { + const base = index * FLAT_RECORD_SLOTS; + this.#floats[base] = x; + this.#floats[base + 1] = y; + } + + setRadius(index: number, radius: number): void { + this.#floats[index * FLAT_RECORD_SLOTS + 2] = radius; + } + + /** The join key: which entity this record is (paired with the EntityId map SAB). */ + setEntityIdx(index: number, entityIdx: number): void { + this.#u32[index * FLAT_RECORD_SLOTS + 4] = entityIdx; + } + + setColor( + index: number, + color: readonly [number, number, number, number], + ): void { + const offset = index * FLAT_RECORD_BYTES + FLAT_COLOR_BYTE_OFFSET; + this.#bytes[offset] = color[0]; + this.#bytes[offset + 1] = color[1]; + this.#bytes[offset + 2] = color[2]; + this.#bytes[offset + 3] = color[3]; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts new file mode 100644 index 00000000000..ef88142dfe8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts @@ -0,0 +1,145 @@ +/* eslint-disable no-bitwise */ +/* eslint-disable no-param-reassign */ +/* eslint-disable operator-assignment */ +/** + * Fixed-universe bit set backed by a Uint32Array. + * + * Used for type ancestor closures: each bit position corresponds to a + * TypeIdx, and set membership means "this type is an ancestor." + * Operations (or, and, intersectionCount, jaccard) are all O(words) + * where words = ceil(universe / 32). + */ + +// eslint-disable-next-line id-length +function popcount(n: number): number { + n = n - ((n >>> 1) & 0x55555555); + n = (n & 0x33333333) + ((n >>> 2) & 0x33333333); + n = (n + (n >>> 4)) & 0x0f0f0f0f; + return (n * 0x01010101) >>> 24; +} + +export class BitSet { + #words: Uint32Array; + #cardinality: number; + + private constructor(words: Uint32Array, cardinality: number) { + this.#words = words; + this.#cardinality = cardinality; + } + + static empty(universeSize: number): BitSet { + const wordCount = Math.ceil(universeSize / 32) || 1; + + return new BitSet(new Uint32Array(wordCount), 0); + } + + static fromBit(universeSize: number, bit: T): BitSet { + const set = BitSet.empty(universeSize); + set.add(bit); + + return set; + } + + get words(): Uint32Array { + return this.#words; + } + + get cardinality(): number { + return this.#cardinality; + } + + has(bit: T): boolean { + const word = bit >>> 5; + const mask = 1 << (bit & 31); + return word < this.#words.length && (this.#words[word]! & mask) !== 0; + } + + #grow(): void { + if (this.#words.buffer.resizable) { + this.#words.buffer.resize(this.#words.buffer.byteLength * 2); + this.#words = new Uint32Array(this.#words.buffer); + } else { + const newWords = new Uint32Array(this.#words.buffer.byteLength * 2); + newWords.set(this.#words); + this.#words = newWords; + } + } + + add(bit: T): void { + const word = bit >>> 5; + const mask = 1 << (bit & 31); + while (word >= this.#words.length) { + this.#grow(); + } + + if ((this.#words[word]! & mask) === 0) { + this.#words[word]! |= mask; + this.#cardinality++; + } + } + + /** Returns a new BitSet that is the union of this and other. */ + or(other: BitSet): BitSet { + const len = Math.max(this.#words.length, other.#words.length); + const result = new Uint32Array(len); + let cardinality = 0; + + for (let index = 0; index < len; index++) { + const word = (this.#words[index] ?? 0) | (other.#words[index] ?? 0); + result[index] = word; + cardinality += popcount(word); + } + + return new BitSet(result, cardinality); + } + + /** Returns a new BitSet that is the intersection of this and other. */ + and(other: BitSet): BitSet { + const len = Math.min(this.#words.length, other.#words.length); + const result = new Uint32Array(len); + let cardinality = 0; + + for (let index = 0; index < len; index++) { + const word = this.#words[index]! & other.#words[index]!; + result[index] = word; + cardinality += popcount(word); + } + + return new BitSet(result, cardinality); + } + + /** Count of bits set in both this and other, without allocating. */ + intersectionCount(other: BitSet): number { + const len = Math.min(this.#words.length, other.#words.length); + let count = 0; + + for (let index = 0; index < len; index++) { + count += popcount(this.#words[index]! & other.#words[index]!); + } + + return count; + } + + /** Jaccard similarity: |A ∩ B| / |A ∪ B|. Returns 1 if both empty. */ + jaccard(other: BitSet): number { + const intersection = this.intersectionCount(other); + const union = this.#cardinality + other.#cardinality - intersection; + return union === 0 ? 1 : intersection / union; + } + + /** Iterate over set bit positions. */ + *members(): IterableIterator { + for (let word = 0; word < this.#words.length; word++) { + let bits = this.#words[word]!; + while (bits !== 0) { + const lsb = bits & -bits; + yield ((word << 5) + Math.log2(lsb)) as T; + bits ^= lsb; + } + } + } + + clone(): BitSet { + return new BitSet(new Uint32Array(this.#words), this.#cardinality); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts new file mode 100644 index 00000000000..4d01bd53ad4 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts @@ -0,0 +1,188 @@ +/** + * A typed array constructor that can create views over a SharedArrayBuffer. + * + * This is the type-level trick that makes Column generic: pass the constructor + * as a value, and TypeScript infers the element type from it. + */ +type BackingBuffer = SharedArrayBuffer | ArrayBuffer; + +const sharedBufferAvailable = typeof SharedArrayBuffer !== "undefined"; + +function allocBuffer(byteLength: number): BackingBuffer { + return sharedBufferAvailable + ? new SharedArrayBuffer(byteLength) + : new ArrayBuffer(byteLength); +} + +export type TypedArrayConstructor = { + new (buffer: BackingBuffer, byteOffset?: number, length?: number): T; + readonly BYTES_PER_ELEMENT: number; +}; + +export type TypedArray = + | Uint8Array + | Uint16Array + | Uint32Array + | Int8Array + | Int16Array + | Int32Array + | Float32Array + | Float64Array; + +/** + * Readonly view over a region of a typed array. + * + * Preserves the branded element type `T` and provides indexed access, + * iteration, and further sub-slicing without exposing mutation. + */ +export class ColumnView { + readonly #view: A; + + constructor(view: A) { + this.#view = view; + } + + get length(): number { + return this.#view.length; + } + + /** The underlying buffer (shared when SharedArrayBuffer is available). */ + get buffer(): BackingBuffer { + return this.#view.buffer as BackingBuffer; + } + + /** The raw typed array backing this view. */ + get view(): A { + return this.#view; + } + + get(idx: number): T { + if (idx < 0 || idx >= this.#view.length) { + throw new RangeError( + `ColumnView: index ${idx} out of bounds [0, ${this.#view.length})`, + ); + } + + return this.#view[idx]! as T; + } + + /** Zero-copy sub-view. Shares the same underlying buffer. */ + subarray(start?: number, end?: number): ColumnView { + return new ColumnView(this.#view.subarray(start, end) as A); + } + + [Symbol.iterator](): ArrayIterator { + return this.#view[Symbol.iterator]() as ArrayIterator; + } +} + +/** + * Growable columnar storage backed by SharedArrayBuffer when available, + * falling back to ArrayBuffer otherwise. + * + * SharedArrayBuffer allows both the worker and main thread to read + * the same memory without serialization. The column grows by doubling + * capacity; when resizable shared buffers are available it resizes in place, + * otherwise it allocates and copies. + * + * Generic over typed array kind via the constructor parameter: + * + * const entities = new Column(Uint32Array, 4096); + * const positions = new Column(Float32Array, 4096); + */ +export class Column { + readonly #ctor: TypedArrayConstructor; + #buffer: BackingBuffer; + #view: A; + #length: number; + + constructor(Ctor: TypedArrayConstructor, initialCapacity: number) { + this.#ctor = Ctor; + this.#buffer = allocBuffer(initialCapacity * Ctor.BYTES_PER_ELEMENT); + this.#view = new Ctor(this.#buffer); + this.#length = 0; + } + + get length(): number { + return this.#length; + } + + /** The underlying buffer. SharedArrayBuffer when available. */ + get buffer(): BackingBuffer { + return this.#buffer; + } + + push(value: T): number { + if (this.#length >= this.#view.length) { + this.#grow(); + } + + const idx = this.#length; + this.#view[idx] = value; + this.#length++; + + return idx; + } + + get(idx: number): T { + if (idx < 0 || idx >= this.#length) { + throw new RangeError( + `Column: index ${idx} out of bounds [0, ${this.#length})`, + ); + } + + return this.#view[idx]! as T; + } + + set(idx: number, value: T): void { + if (idx < 0 || idx >= this.#length) { + throw new RangeError( + `Column: index ${idx} out of bounds [0, ${this.#length})`, + ); + } + + this.#view[idx] = value; + } + + /** Zero-copy view over the filled portion, or a sub-range of it. */ + subarray(start?: number, end?: number): ColumnView { + const filled = new this.#ctor(this.#buffer, 0, this.#length); + + return new ColumnView(filled.subarray(start, end) as A); + } + + /** Copy the filled portion (or a sub-range) into a new independent Column. */ + slice(start?: number, end?: number): Column { + const source = this.subarray(start, end); + const col = new Column(this.#ctor, source.length); + for (const value of source) { + col.push(value); + } + return col; + } + + #grow(): void { + const newCapacity = Math.max(1, this.#view.length * 2); + const newByteLength = newCapacity * this.#ctor.BYTES_PER_ELEMENT; + + if ( + sharedBufferAvailable && + this.#buffer instanceof SharedArrayBuffer && + this.#buffer.growable && + this.#buffer.maxByteLength >= newByteLength + ) { + this.#buffer.grow(newByteLength); + this.#view = new this.#ctor(this.#buffer); + } else { + const next = allocBuffer(newByteLength); + const nextView = new this.#ctor(next); + nextView.set(this.#view); + this.#buffer = next; + this.#view = nextView; + } + } + + [Symbol.iterator](): ArrayIterator { + return this.subarray()[Symbol.iterator](); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts new file mode 100644 index 00000000000..0ed69512752 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts @@ -0,0 +1,55 @@ +/** + * Bidirectional string interner. + * + * Assigns a stable integer index to each unique string value. + * Used for entity IDs, type-set keys, and versioned URLs to avoid + * storing millions of repeated string references. + */ +export class Interner { + readonly #map: Map; + readonly #values: In[]; + + constructor() { + this.#map = new Map(); + this.#values = []; + } + + tryIntern(value: In): [boolean, Out] { + const existing = this.#map.get(value); + if (existing !== undefined) { + return [false, existing]; + } + + const index = this.#values.length as Out; + this.#values.push(value); + this.#map.set(value, index); + + return [true, index]; + } + + /** Get or create an index for the given value. */ + intern(value: In): Out { + const [, index] = this.tryIntern(value); + return index; + } + + /** Get the index for a value, or undefined if not interned. */ + tryGet(value: In): Out | undefined { + return this.#map.get(value); + } + + /** Get the value for an index. */ + getValue(idx: Out): In { + const value = this.#values[idx]; + + if (value === undefined) { + throw new Error(`Interner: no value at index ${idx}`); + } + + return value; + } + + get size(): number { + return this.#values.length; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts new file mode 100644 index 00000000000..08c657367ae --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts @@ -0,0 +1,82 @@ +function binarySearch( + sorted: readonly T[], + target: T, + compare: (lhs: T, rhs: T) => number, +): number { + let lo = 0; + let hi = sorted.length - 1; + + while (lo <= hi) { + // eslint-disable-next-line no-bitwise + const mid = (lo + hi) >>> 1; + const val = sorted[mid]!; + + const comparison = compare(val, target); + if (comparison === 0) { + return mid; + } + if (comparison < 0) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return -1; +} + +/** + * An immutable, deduplicated, sorted set of numbers. + * + * Constructed once with deduplication and sorting, then read-only. + * Used for type-set keys: the canonical representation of an entity's + * direct type indices. + */ +export class ReadonlySortedSet { + readonly #items: readonly T[]; + readonly #compare: (lhs: T, rhs: T) => number; + + constructor(values: Iterable, compare: (lhs: T, rhs: T) => number) { + this.#items = [...new Set(values)].sort(compare); + this.#compare = compare; + } + + get items(): readonly T[] { + return this.#items; + } + + get size(): number { + return this.#items.length; + } + + has(value: T): boolean { + return binarySearch(this.#items, value, this.#compare) >= 0; + } + + /** Whether every item in this set is also in other. */ + isSubsetOf(other: ReadonlySortedSet): boolean { + const lhs = this.#items; + const rhs = other.#items; + let lhsIdx = 0; + let rhsIdx = 0; + + while (lhsIdx < lhs.length && rhsIdx < rhs.length) { + const comparison = this.#compare(lhs[lhsIdx]!, rhs[rhsIdx]!); + + if (comparison === 0) { + lhsIdx++; + rhsIdx++; + } else if (comparison < 0) { + rhsIdx++; + } else { + return false; + } + } + + return lhsIdx === lhs.length; + } + + *[Symbol.iterator](): IterableIterator { + yield* this.#items; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts new file mode 100644 index 00000000000..d2b70463972 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts @@ -0,0 +1,2972 @@ +import { validateConfig } from "../../config"; +import { dimColor } from "../../dim-color"; +import { ClusterId } from "../../ids"; +import { graphColors } from "../../visual-style"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { ReadonlySortedSet } from "../collections/readonly-sorted-set"; +import { + colorForType, + edgeColorForType, + FRONTIER_COLOR, + primaryTypeOfSet, + radiusForDegree, +} from "../entity-style"; +import { PortCache, computeAllPorts } from "../geometry/bubble-ports"; +import { + CutIndex, + EdgeAggregator, + makePairKey, +} from "../geometry/edge-aggregation"; +import { + analyzeHierarchy, + BezierSegmentSink, + buildBezierSegments, + containerBoundaryWaypoint, + highwayEndpoints, + portsFor, +} from "../geometry/edge-geometry"; +import { syncWorldPositions } from "../geometry/world-positions"; +import { createClusterFeatureSource } from "../hierarchy/cluster-feature-source"; +import { ClusterTree, colorForCluster } from "../hierarchy/cluster-tree"; +import { + type ClusterMembers, + nameClustersByDistinctiveFeatures, +} from "../hierarchy/distinctive-cluster-label"; +import { LodState, computeVisibleCut } from "../hierarchy/lod"; +import { createClusterLayout } from "../layout/cluster-layout"; +import { createCommunityLayout } from "../layout/community-layout"; +import { createEntityLayout } from "../layout/entity-layout"; +import { createFlatLayout } from "../layout/flat-layout"; +import { sharedBufferAvailable } from "../layout/force-simulation"; +import { optimizeTopLevel } from "../layout/top-level-layout"; +import { untangleLayout } from "../layout/untangle"; +import { EntityStore } from "../stores/entity-store"; +import { LinkStore } from "../stores/link-store"; +import { PropertyStore } from "../stores/property-store"; +import { TypeRegistry } from "../stores/type-registry"; +import { TypeSetStore } from "../stores/type-set-store"; +import { layoutNeedsRebuild } from "./layout-reuse"; +import { viewportAnchorWeight } from "./viewport-anchor"; + +import type { VizConfig } from "../../config"; +import type { + Color, + HighwayLaneSummary, + PositionsFrame, + RenderCluster, + RenderEdgeArrow, + RenderEdgeLabel, + RenderEntityFanOut, + RenderEntityLayer, + RenderFlatGraph, + StructureFrame, +} from "../../frames"; +import type { EntityIdx, TypeSetIdx, TypeSetKey, VizMode } from "../../ids"; +import type { RepublishHandler } from "../buffers/growable-buffer"; +import type { Port } from "../geometry/bubble-ports"; +import type { EdgeFrame } from "../geometry/edge-aggregation"; +import type { ClusterNode, IngestDelta } from "../hierarchy/cluster-tree"; +import type { ViewportState } from "../hierarchy/lod"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, + PortAnchor, +} from "../layout/force-simulation"; +import type { Anchor, LayoutNode } from "../layout/top-level-layout"; +import type { UntangleNode } from "../layout/untangle"; +import type { + BufferRepublishedMessage, + EgoTarget, + EmbeddingClusteringNeededMessage, + EntityIdMapMessage, + IngestEntity, + LayoutCreatedMessage, + LayoutDestroyedMessage, + LayoutPositionsMessage, + PropertySchemaEntry, + TypeSchemaEntry, +} from "../protocol"; +import type { TypeSetGroup } from "../stores/type-set-store"; +import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; + +/** Above this node count, skip the D1 untangle (force result stands). */ +const UNTANGLE_MAX_NODES = 48; + +/** Above this top-level cluster count, skip the optimiser (keep WebCola's result). */ +const TOP_LEVEL_MAX_NODES = 32; + +/** Stable non-bitwise string hash -> seed for the deterministic untangle PRNG. */ +function hashId(id: string): number { + let hash = 0; + for (let idx = 0; idx < id.length; idx++) { + hash = (hash * 31 + id.charCodeAt(idx)) % 2147483647; + } + return hash; +} + +/** What a force layout's nodes represent: child cluster bubbles or entities. */ +type LayoutKind = "clusters" | "entities"; + +type PortPairs = ReadonlyMap< + string, + { readonly source: Port; readonly target: Port } +>; + +/** A visible cluster plus its container nesting depth (0 = leaf/standalone). */ +interface RenderedEntry { + readonly node: ClusterNode; + readonly depth: number; +} + +/** + * A flat-tier render edge: local node indices into the flat layout plus the + * link's own type colour. Rebuilt each commit (topology + colour); the per-tick + * geometry just reads the two nodes' current positions for these. + */ +interface FlatRenderEdge { + readonly sourceIdx: number; + readonly targetIdx: number; + readonly color: Color; + /** The link's own EntityIdx, so a picked edge resolves to its link entity. */ + readonly linkEntityIdx: EntityIdx; +} + +const FAN_OUT_COLOR: Color = [...graphColors.fanOutEdge]; + +/** Uniform entity-dot radius as a fraction of the parent bubble radius. */ +const ENTITY_RADIUS_FRACTION = 0.02; + +const ROOT_ID = ClusterId("cluster:root"); + +/** The single layout id for the whole-graph flat-tier (individual-entity) layout. */ +const FLAT_LAYOUT_ID = ClusterId("flat:all"); + +/** Seed offset (world units) for a streamed node placed beside a placed neighbour. */ +const FLAT_SEED_NEIGHBOUR_OFFSET = 24; +/** Phyllotaxis disk scale (world units) for cold-start / orphan flat nodes. */ +const FLAT_SEED_DISK_SCALE = 28; +/** Flat-tier edge stroke width in world units (the layer scales it with zoom). */ +const FLAT_EDGE_WIDTH_WORLD = 1.2; +/** + * SharedArrayBuffer capacity for a flat (re)build, over-allocated past the live + * count so the community-force tier can append streamed nodes into spare records + * (warm FA2 absorb: bump count + version, one atomic sync, no buffer realloc / + * re-send). Geometric headroom, so a rebuild only happens when a batch overflows it. + */ +function flatCapacityFor(count: number): number { + return Math.max(count + 64, Math.ceil(count * 1.5)); +} + +/** After community-force ingests go quiet for this long (ms), run one trailing + * Louvain so the BubbleSets reflect the settled graph; the last batch may not + * have crossed the growth-fraction refresh threshold. */ +const FLAT_LOUVAIN_LINGER_MS = 100; +const SLOW_TICK_WARNING_MS = 10; + +export class GraphWorker { + readonly config: VizConfig; + + readonly #types: TypeRegistry = new TypeRegistry(); + readonly #typeSets: TypeSetStore = new TypeSetStore(); + /** Wired into the EntityStore's EntityIdx->EntityId join map: on the rare re-allocation + * (past its ceiling), re-send the buffer so the main thread swaps to it. The same + * message as the first publish; the map is read on demand, so "here is the current + * buffer" is all the main thread needs. */ + readonly #republishEntityIdMap: RepublishHandler = (raw, capacity) => { + this.#onLayoutMessage?.({ type: "ENTITY_ID_MAP", buffer: raw, capacity }); + }; + + /** Owns interning and the join map (it writes each EntityId the instant it assigns the + * EntityIdx, so the map is always current, no mirror pass). The worker just publishes + * the buffer to the main thread (once, plus the republish handler above). */ + readonly #entities: EntityStore = new EntityStore(this.#republishEntityIdMap); + /** Whether the first ENTITY_ID_MAP publish has gone out. */ + #entityIdMapPublished = false; + readonly #links: LinkStore = new LinkStore(); + /** Per-entity property features + property titles, used to NAME embedding clusters. */ + readonly #properties: PropertyStore = new PropertyStore(); + + readonly #clusterTree = new ClusterTree(); + readonly #portCache = new PortCache(); + readonly #edgeAggregator = new EdgeAggregator(); + + /** Reused flat-array scratch for Bezier segments; snapshot()ed per frame. */ + readonly #bezierSink = new BezierSegmentSink(); + readonly #pendingEmbeddingRequests: EmbeddingClusteringNeededMessage[] = []; + readonly #forceLayouts = new Map(); + /** Per entity-layout, the live port-attraction targets (shared with its force). */ + readonly #entityPortTargets = new Map(); + /** Per opened container, the external endpoint ids its port anchors track. */ + readonly #anchorEndpoints = new Map(); + readonly #layoutKind = new Map(); + + // D1 untangle: inter-sibling edges as node-index pairs (computed at layout + // creation), and the set of cluster layouts already polished (so the + // post-settle untangle runs once per layout, not every tick). + readonly #clusterEdges = new Map(); + readonly #untangled = new Set(); + + /** + * Last committed LOCAL positions of the root's top-level children, keyed by + * cluster id. Persisted across layout recreation AND hierarchy rebuilds (which + * recreate family nodes as fresh objects), so the top level keeps its + * arrangement when a cluster is added/removed instead of being re-solved from + * scratch. Read to warm-seed a recreated root layout and to anchor + * {@link optimizeTopLevel}; refreshed from the live layout on every commit. + */ + readonly #topLevelPositions = new Map(); + + #lodState: LodState = new LodState(); + #viewport: ViewportState | undefined; + /** A pinned leaf cluster: kept open (with its ancestors) regardless of zoom, until the + * selection that set it is cleared. Drives {@link #pinnedOpenSet}. */ + #pinnedLeaf: ClusterId | undefined; + /** Entities kept at full colour while a highlight is active (a selection's ego now, a path + * later); everyone else dims. Empty = no highlight. Set via {@link setHighlight}. */ + #highlightedEntities = new Set(); + + /** Set when an expand flips an already-rendered frontier node to a root. The flat tier restyles + * every commit, but the hierarchical leaf path does not, so the worker re-applies styling after + * the commit when this is set (see {@link restyleIfRootsFlipped}). */ + #rootFlipPending = false; + #mode: VizMode = "flat-force"; + /** Count of loaded NODE entities (excludes interned links). See {@link nodeCount}. */ + #nodeEntityCount = 0; + /** True when the committed state is the hierarchical (cluster-tree) regime. */ + #hierarchicalActive = false; + /** Flat-tier render edges (one per link: local indices + link-type colour), + * rebuilt each commit; the per-tick bezier geometry reads node positions for them. */ + #flatRenderEdges: FlatRenderEdge[] = []; + /** Interleaved SharedArrayBuffer backing the flat layout (positions + radii + colours). */ + #flatBuffer: FlatGraphBuffer | undefined; + /** + * Wired into every flat {@link FlatGraphBuffer}: when it outgrows its capacity it + * re-allocates (the buffer is non-resizable; its bytes are GPU-uploaded, and WebGL + * rejects views over a resizable buffer), so hand the main thread the new buffer to swap + * to + re-watch. Appends within capacity fire nothing (they fill spare records in place). + */ + readonly #republishFlatBuffer: RepublishHandler = (raw, capacity) => { + this.#onLayoutMessage?.({ + type: "BUFFER_REPUBLISHED", + target: { kind: "layout", clusterId: FLAT_LAYOUT_ID }, + buffer: raw, + capacity, + }); + }; + + /** Link count at the last flat-layout (re)build; a change forces a rebuild. */ + #flatLinkCount = -1; + /** Which engine the current flat layout was built for ("flat-force" -> cola, + * "community-force" -> FA2). Crossing that boundary forces a rebuild. */ + #flatLayoutMode: VizMode | undefined; + /** Trailing-debounce timer: one final Louvain once community-force ingests quiet. */ + #flatLingerTimer: ReturnType | undefined; + #structureVersion = 0; + #positionVersion = 0; + + /** Committed visible clusters, in a stable order; positions index-align. */ + #rendered: RenderedEntry[] = []; + #renderedIndex = new Map(); + + /** Cached topology from the last structure commit; reused by position ticks. */ + #cutIndex: CutIndex | undefined; + #edgeFrame: EdgeFrame | undefined; + /** Per-lane link-entity unions for the CURRENT structure, indexed by laneId. A merged + * highway's lanes all share one union (the whole ribbon's links); set by #buildHighwayLanes, + * read by highwayLinks on click. */ + #highwayLaneUnions: EntityIdx[][] = []; + + // MessageChannel-based simulation scheduler. + readonly #schedulerChannel = new MessageChannel(); + #schedulerRunning = false; + + // MessageChannel-based deferral for one-shot background jobs (cluster naming): each + // job runs as a macro task that yields to the event loop first, so a job that scans + // every member's properties never blocks the commit that just rendered the clusters. + readonly #jobChannel = new MessageChannel(); + readonly #jobs: Array<() => void> = []; + + #onLayoutMessage: + | (( + msg: + | LayoutCreatedMessage + | LayoutDestroyedMessage + | LayoutPositionsMessage + | BufferRepublishedMessage + | EntityIdMapMessage, + ) => void) + | undefined; + + #onStructureFrame: ((frame: StructureFrame) => void) | undefined; + #onPositionsFrame: ((frame: PositionsFrame) => void) | undefined; + + constructor(config: VizConfig) { + validateConfig(config); + this.config = config; + + // The port1.onmessage handler is the tick loop. Posting to port2 schedules + // a macro task that yields to the event loop (incoming messages get + // processed) without the ~4ms setTimeout floor. + this.#schedulerChannel.port1.onmessage = () => { + this.#tickAllLayouts(); + if (this.#schedulerRunning) { + this.#scheduleNextTick(); + } + }; + + // Run one deferred job per turn, re-posting while the queue is non-empty so each + // yields to the event loop (incoming messages, the prior commit's paint) between jobs. + this.#jobChannel.port1.onmessage = () => { + this.#jobs.shift()?.(); + if (this.#jobs.length > 0) { + this.#jobChannel.port2.postMessage(undefined); + } + }; + } + + get debug(): boolean { + return this.config.debug ?? false; + } + + set onStructureFrame(handler: ((frame: StructureFrame) => void) | undefined) { + this.#onStructureFrame = handler; + } + + set onPositionsFrame(handler: ((frame: PositionsFrame) => void) | undefined) { + this.#onPositionsFrame = handler; + } + + set onLayoutMessage( + handler: + | (( + msg: + | LayoutCreatedMessage + | LayoutDestroyedMessage + | LayoutPositionsMessage + | BufferRepublishedMessage + | EntityIdMapMessage, + ) => void) + | undefined, + ) { + this.#onLayoutMessage = handler; + } + + #scheduleNextTick(): void { + this.#schedulerChannel.port2.postMessage(undefined); + } + + #ensureSchedulerRunning(): void { + if (!this.#schedulerRunning) { + this.#schedulerRunning = true; + this.#scheduleNextTick(); + } + } + + /** Queue a one-shot background job onto the deferral channel (see {@link #jobChannel}). */ + #scheduleJob(job: () => void): void { + this.#jobs.push(job); + if (this.#jobs.length === 1) { + this.#jobChannel.port2.postMessage(undefined); + } + } + + /** True while any cluster-level (macro/container) layout is still moving. */ + #anyClusterLayoutRunning(): boolean { + for (const [clusterId, layout] of this.#forceLayouts) { + if ( + this.#layoutKind.get(clusterId) === "clusters" && + layout.status === "running" + ) { + return true; + } + } + return false; + } + + /** Any layout (cluster or entity) still running, drives scheduler shutdown. */ + #anyLayoutRunning(): boolean { + for (const layout of this.#forceLayouts.values()) { + if (layout.status === "running") { + return true; + } + } + return false; + } + + /** + * One simulation step across all active layouts. + * + * Entity layouts write their positions to a SharedArrayBuffer and notify the + * main thread directly (no message, no frame). Cluster layouts write back to + * their child circles; when any cluster moved, a PositionsFrame is emitted so + * the bubbles and the highways that follow them stay in sync. The expensive + * topology pipeline (cut, CutIndex, aggregation) is never touched here. + */ + #tickAllLayouts(): void { + const tickStart = performance.now(); + const clustersRunningBefore = this.#anyClusterLayoutRunning(); + let clusterMoved = false; + let flatMoved = false; + + for (const [clusterId, layout] of this.#forceLayouts) { + if (layout.status === "settled" || layout.status === "paused") { + continue; + } + + const changed = layout.tick(1); + + const kind = this.#layoutKind.get(clusterId); + + if (kind === "entities") { + if (changed && clusterId === FLAT_LAYOUT_ID) { + // Flat edges are worker-built beziers, so emit a frame when the layout + // moves so they track the shared-buffer-streamed dots. (Non-shared-buffer + // periodic sync of the interleaved flat buffer is deferred, see MANIFESTO.) + flatMoved = true; + } else if (changed && !sharedBufferAvailable) { + // Hierarchical entity layout, non-shared-buffer fallback: post position + // snapshots. With a SharedArrayBuffer the buffer is written in place and + // the main thread is woken via Atomics. + const positions = new Float32Array(layout.nodes.length * 2); + for (let idx = 0; idx < layout.nodes.length; idx++) { + const node = layout.nodes[idx]!; + positions[idx * 2] = node.x ?? 0; + positions[idx * 2 + 1] = node.y ?? 0; + } + this.#onLayoutMessage?.({ + type: "LAYOUT_POSITIONS", + clusterId, + positions, + }); + } + continue; + } + + // Cluster layout moved, so the top-down propagation pass below writes the + // world circles (it must run for settled intermediates too, so it can't + // live here in the per-ticked-layout loop). + const cluster = this.#clusterTree.get(clusterId); + if ( + changed && + cluster && + cluster.children.length === layout.nodes.length + ) { + clusterMoved = true; + } + + // The tick it settles, polish positions once (root -> optimiser, + // sub-cluster -> untangle). Also runs from #ensureChildrenLayout for + // layouts that settle during their warm-up (which this loop would skip). + if (cluster && layout.isSettled && !this.#untangled.has(clusterId)) { + this.#polishSettledLayout(cluster, layout); + clusterMoved = true; + } + } + + // Emit a PositionsFrame when clusters moved, and one final frame on the + // tick where the last cluster layout settles (so `settled` reaches main). + const clustersJustSettled = + clustersRunningBefore && !this.#anyClusterLayoutRunning(); + if (clusterMoved || clustersJustSettled) { + // Recompose world positions top-down first (authoritative, see + // #syncWorldPositions), so anchor re-aiming below reads correct, + // fully-propagated circles even through settled depth >= 2 layouts. + this.#syncWorldPositions(); + if (clusterMoved) { + // Re-aim opened sub-clusters' port anchors at their (moved) external + // neighbours, in place (no re-run, no structure emit). Everything + // positional (cluster positions, geometry, and entity fan-out exits + + // force targets) travels in the PositionsFrame; structure is re-emitted + // only on a real cut change (never per tick, that was the OOM). + this.#updateAnchorTracking(); + } + this.#emitPositions(); + } else if (flatMoved) { + // Flat tier: no clusters, no world-sync, just refresh the edge beziers + // from the moved node positions (the dots themselves stream via the + // shared buffer). + this.#emitPositions(); + } + + // Keep ticking while any layout is running, including an entity layout just + // re-energised by a moved port target (it hasn't ticked this turn, so the + // old "did a layout tick?" check would wrongly stop the scheduler). + if (!this.#anyLayoutRunning()) { + this.#schedulerRunning = false; + } + + // Gated so a settled idle doesn't spam: only slow ticks (force step + + // port/Bezier refresh) are worth seeing. + const elapsed = performance.now() - tickStart; + if (this.debug && elapsed > SLOW_TICK_WARNING_MS) { + // eslint-disable-next-line no-console + console.warn( + `[graph-worker][slow tick] ${elapsed.toFixed(1)}ms ` + + `(${this.#forceLayouts.size} layouts)`, + ); + } + } + + get mode(): VizMode { + return this.#mode; + } + + /** + * Loaded NODE entities only. `EntityStore` interns links too (a link is an + * entity), so its `size` over-counts; the mode thresholds are defined on the + * non-link count (see `config`), so they must read this. + */ + get nodeCount(): number { + return this.#nodeEntityCount; + } + + get linkCount(): number { + return this.#links.count; + } + + /** + * The ego of a selected node: for each neighbor (from the full link store, so cross-cluster + * and both tiers are covered), the representative currently on screen -- the entity itself + * when individually rendered (flat, or an open leaf), else the visible cluster it collapses + * into. Neighbors not in view are omitted. The main thread highlights dots + bubbles from + * this. O(degree); per selection, not per frame. + */ + ego(entityIdx: EntityIdx): EgoTarget[] { + const cutIndex = this.#cutIndex; + const targets = new Map(); + for (const link of this.#links.linksForEntity(entityIdx)) { + const neighbor = link.otherIdx; + + if (!cutIndex) { + // Flat tier: no cut -- every entity is an individually-rendered dot. + targets.set(`e${neighbor}`, { kind: "entity", entityIdx: neighbor }); + continue; + } + + const owner = cutIndex.ownerOf(neighbor); + if (owner === undefined) { + continue; // not in the current view + } + + if (cutIndex.isEntityMode(owner)) { + targets.set(`e${neighbor}`, { kind: "entity", entityIdx: neighbor }); + } else { + targets.set(`c${owner}`, { kind: "cluster", clusterId: owner }); + } + } + return [...targets.values()]; + } + + registerTypes( + schemas: readonly TypeSchemaEntry[], + propertySchemas: readonly PropertySchemaEntry[], + ): void { + this.#types.registerAll(schemas); + this.#properties.registerTitles(propertySchemas); + } + + /** + * Insert a node entity. Returns null if duplicate, otherwise + * the entity index and the group it was assigned to. + */ + insertNodeEntity( + entity: IngestEntity, + ): { entityIdx: EntityIdx; groupKey: TypeSetKey } | undefined { + const [created, entityIdx] = this.#entities.tryInsert(entity.entityId); + // Apply root-ness even for an already-interned entity: an expand re-sends a frontier node as a + // root, and this is what flips it. A flip of an already-rendered node needs a restyle the + // commit alone won't do in the hierarchical tier (see restyleIfRootsFlipped). + if (entity.isRoot) { + const flippedExisting = this.#entities.setRoot(entityIdx) && !created; + if (flippedExisting) { + this.#rootFlipPending = true; + } + } + if (!created) { + return undefined; + } + this.#nodeEntityCount += 1; + + const directTypeIdxs = new ReadonlySortedSet( + entity.entityTypeIds.map((url) => this.#types.intern(url)), + (lhs, rhs) => lhs - rhs, + ); + + const group = this.#typeSets.getOrCreate(directTypeIdxs, this.#types.size); + group.addEntity(entityIdx); + this.#entities.setTypeGroup(entityIdx, group.idx); + // Reduce the entity's properties to its interned features now, while ingesting, so a + // later cluster-naming pass just tallies integers (see {@link PropertyStore}). + this.#properties.ingest(entityIdx, entity.properties); + this.#resolvePendingLinks(entity.entityId, entityIdx); + + return { entityIdx, groupKey: group.key }; + } + + insertLinkEntity(entity: IngestEntity): void { + if (!entity.linkData) { + return; + } + + const [created, linkEntityIdx] = this.#entities.tryInsert(entity.entityId); + if (!created) { + return; + } + + const leftIdx = this.#entities.tryGet(entity.linkData.leftEntityId) ?? -1; + const rightIdx = this.#entities.tryGet(entity.linkData.rightEntityId) ?? -1; + + const linkTypeIdxs = new ReadonlySortedSet( + entity.entityTypeIds.map((url) => this.#types.intern(url)), + (lhs, rhs) => lhs - rhs, + ); + const linkGroup = this.#typeSets.getOrCreate( + linkTypeIdxs, + this.#types.size, + ); + + const linkIdx = this.#links.insert( + leftIdx, + rightIdx, + linkGroup.idx, + linkEntityIdx, + ); + + if (leftIdx === -1) { + this.#links.addPending(entity.linkData.leftEntityId, linkIdx); + } + if (rightIdx === -1) { + this.#links.addPending(entity.linkData.rightEntityId, linkIdx); + } + } + + /** + * Ingest a batch of entities, returning per-group deltas + * for the incremental update path. + */ + ingestBatch(entities: readonly IngestEntity[]): IngestDelta[] { + const groupSnapshots = new Map< + TypeSetKey, + { before: number; isNew: boolean } + >(); + const links: IngestEntity[] = []; + + for (const entity of entities) { + if (entity.isLink) { + links.push(entity); + continue; + } + + // Snapshot count BEFORE insert so we can compute deltas. + const group = this.#peekGroup(entity); + if (group && !groupSnapshots.has(group.key)) { + groupSnapshots.set(group.key, { + before: group.count, + isNew: group.count === 0, + }); + } + + this.insertNodeEntity(entity); + } + + for (const entity of links) { + this.insertLinkEntity(entity); + } + + const deltas: IngestDelta[] = []; + for (const [groupKey, { before, isNew }] of groupSnapshots) { + const group = this.#typeSets.get(groupKey)!; + const delta = group.count - before; + if (delta > 0) { + deltas.push({ + groupKey, + delta, + isNewGroup: isNew, + previousCount: before, + }); + } + } + + return deltas; + } + + /** + * Peek at which group an entity would land in without inserting. + * Used to snapshot counts before insert. + */ + #peekGroup(entity: IngestEntity): TypeSetGroup | undefined { + if (this.#entities.tryGet(entity.entityId) !== undefined) { + return undefined; // Already inserted, skip. + } + + const directTypeIdxs = new ReadonlySortedSet( + entity.entityTypeIds.map((url) => this.#types.intern(url)), + (lhs, rhs) => lhs - rhs, + ); + + return this.#typeSets.getOrCreate(directTypeIdxs, this.#types.size); + } + + recomputeMode(): VizMode { + const { nodeCount, config } = this; + const mode = this.#mode; + + let next = mode; + if (mode === "flat-force" && nodeCount > config.flatLayoutExitNodes) { + next = + nodeCount > config.communityColorExitNodes + ? "hierarchical-lod" + : "community-force"; + } else if ( + mode === "community-force" && + nodeCount > config.communityColorExitNodes + ) { + next = "hierarchical-lod"; + } else if ( + mode === "community-force" && + nodeCount < config.flatLayoutMaxNodes + ) { + next = "flat-force"; + } else if ( + mode === "hierarchical-lod" && + nodeCount < config.communityColorMaxNodes + ) { + next = "community-force"; + } + + this.#mode = next; + return next; + } + + /** + * Full rebuild. Used on first ingest or when the incremental + * path can't handle a structural change. + */ + rebuildClusters(): void { + // Full rebuild replaces the entire tree. All existing layouts are invalid. + for (const clusterId of this.#forceLayouts.keys()) { + if (this.#layoutKind.get(clusterId) === "entities") { + this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId }); + } + } + this.#forceLayouts.clear(); + this.#layoutKind.clear(); + this.#entityPortTargets.clear(); + this.#anchorEndpoints.clear(); + this.#clusterEdges.clear(); + this.#untangled.clear(); + this.#portCache.clear(); + this.#edgeAggregator.reset(); + this.#cutIndex = undefined; + this.#edgeFrame = undefined; + + this.#clusterTree.rebuild( + this.#typeSets, + this.#types, + this.config, + this.nodeCount, + ); + } + + /** + * Incremental update. Applies deltas from an ingest batch + * to the existing cluster tree. + */ + updateClusters(deltas: readonly IngestDelta[]): void { + this.#clusterTree.updateIncrementally( + deltas, + this.#typeSets, + this.#types, + this.config, + this.nodeCount, + ); + } + + get hasClusters(): boolean { + return !this.#clusterTree.isEmpty; + } + + /** Sum of entity counts across all atomic clusters. For diagnostics. */ + get clusterEntitySum(): number { + return this.#clusterTree.atomicSum(); + } + + /** + * Record a new viewport and react. Pan/zoom that doesn't change the LOD cut is handled by + * Deck.gl projection on the main thread; only an actual cut change commits new structure. + */ + handleViewport(viewport: ViewportState): void { + this.#viewport = viewport; + + // Flat tiers have no worker-side LOD; pan/zoom is pure Deck.gl on the main + // thread (labels/icons re-evaluate there); the cut only exists in the + // hierarchical tier. (#viewport is still recorded above, so the first + // hierarchical commit after a scale-up has a viewport to cut against.) + if (this.#mode !== "hierarchical-lod" || !this.hasClusters) { + return; + } + + const cut = computeVisibleCut( + this.#clusterTree, + ROOT_ID, + viewport, + this.#lodState, + this.config, + (node) => this.#trySubdivide(node), + this.#pinnedOpenSet(), + ); + + if (this.#lodState.wouldChange(cut)) { + this.commitStructure(); + } + } + + /** + * Pin a leaf cluster open (with its ancestors) regardless of zoom, until cleared with + * undefined -- the birds-eye view for a selection. Recomputes the cut if the pin changed. + */ + pin(leafId: ClusterId | undefined): void { + if (this.#pinnedLeaf === leafId) { + return; + } + this.#pinnedLeaf = leafId; + if (this.#mode === "hierarchical-lod" && this.hasClusters) { + this.commitStructure(); + } + } + + // The pinned leaf plus all its ancestors (the path the cut must keep open), or empty. + #pinnedOpenSet(): ReadonlySet { + const set = new Set(); + if (this.#pinnedLeaf === undefined) { + return set; + } + + let node: ClusterNode | null = + this.#clusterTree.get(this.#pinnedLeaf) ?? null; + + while (node) { + set.add(node.id); + node = node.parent; + } + + return set; + } + + /** + * Set the highlighted entities (a selection's ego now, a path later): they keep full colour + * and everyone else dims, so the highlight pops. Empty restores full colour. Generic -- the + * worker just dims the complement; the main thread decides what the set is. + */ + setHighlight(entityIdxs: readonly EntityIdx[]): void { + this.#highlightedEntities = new Set(entityIdxs); + this.#applyHighlight(); + } + + // Re-write node colours (flat SAB + open leaf SABs) honouring the current highlight, and + // re-emit so the edge beziers pick it up too. Inline buffer mutation + notify -- no rebuild. + #applyHighlight(): void { + const flatLayout = this.#forceLayouts.get(FLAT_LAYOUT_ID); + if (flatLayout && this.#flatBuffer) { + this.#writeFlatStyle(flatLayout, this.#flatBuffer); + } + if (this.#cutIndex) { + for (const leafId of this.#cutIndex.entityModeIds) { + const cluster = this.#clusterTree.get(leafId); + const layout = this.#forceLayouts.get(leafId); + if (cluster && layout) { + this.#writeLeafColors(cluster, layout); + } + } + } + this.#emitPositions(); + } + + /** + * Re-style after an expand flipped an already-rendered frontier node to a root. The flat tier + * already restyled in {@link #commitFlat} (its `#writeFlatStyle` runs every commit); only the + * hierarchical tier, whose leaf colours are not rewritten for an unchanged leaf, needs this. + */ + restyleIfRootsFlipped(): void { + if (!this.#rootFlipPending) { + return; + } + this.#rootFlipPending = false; + if (this.#mode === "hierarchical-lod") { + this.#applyHighlight(); + } + } + + /** + * Commit a new topology: compute the visible cut (the only place that may + * subdivide and that mutates LOD state), create/destroy layouts, recompute + * edge aggregation, and emit a StructureFrame plus an initial PositionsFrame. + * + * Heavy O(entities)/O(links) work lives here and runs only on real topology + * changes (ingest, cut change, embedding result), never on a position tick. + */ + commitStructure(opts?: { + readonly deltas?: readonly IngestDelta[]; + readonly rebuildTree?: boolean; + }): void { + this.recomputeMode(); + + // The EntityStore keeps the EntityIdx->EntityId join map's contents current on intern; + // the worker only has to hand the main thread the buffer to read, once. + this.#publishEntityIdMapOnce(); + + // Flat tiers (flat-force / community-force) render the whole entity set as + // one individual-entity graph, no cluster tree (LAYOUT-MODES "Why three + // tiers"). flat-force and community-force are one regime; only crossing the + // hierarchical boundary tears the other regime's state down. + if (this.#mode !== "hierarchical-lod") { + if (this.#hierarchicalActive) { + this.#tearDownHierarchical(); + } + this.#hierarchicalActive = false; + this.#commitFlat(); + return; + } + + if (!this.#hierarchicalActive) { + this.#tearDownFlat(); + } + + // Cluster-tree maintenance lives here, not in entry.ts, so a tree rebuild + // can never clear the flat layout out from under flat mode. Rebuild on the + // first build, when re-entering the hierarchical regime, or when types + // changed; otherwise apply the incremental ingest deltas. + if (opts?.rebuildTree || !this.#hierarchicalActive || !this.hasClusters) { + this.rebuildClusters(); + } else if (opts?.deltas && opts.deltas.length > 0) { + this.updateClusters(opts.deltas); + } + this.#hierarchicalActive = true; + + if (!this.hasClusters) { + this.#rendered = []; + this.#renderedIndex.clear(); + this.#cutIndex = undefined; + this.#edgeFrame = undefined; + this.#emitStructure([]); + this.#emitPositions(); + return; + } + + const activeLayouts = new Set(); + + // The macro layout (top-level clusters) always exists; it seeds and settles + // the bubble positions that everything else hangs off of. + if (this.#clusterTree.root.children.length > 0) { + this.#ensureChildrenLayout(this.#clusterTree.root); + activeLayouts.add(ROOT_ID); + } + + const rendered: RenderedEntry[] = []; + + if (!this.#viewport) { + // Before the first viewport: show the top-level bubbles, no edges. + for (const child of this.#clusterTree.root.children) { + rendered.push({ node: child, depth: 0 }); + } + this.#commitRendered(rendered, activeLayouts); + this.#cutIndex = undefined; + this.#edgeFrame = undefined; + this.#emitStructure([]); + this.#emitPositions(); + return; + } + + const cut = computeVisibleCut( + this.#clusterTree, + ROOT_ID, + this.#viewport, + this.#lodState, + this.config, + (node) => this.#trySubdivide(node), + this.#pinnedOpenSet(), + ); + this.#lodState.applyVisibleCut(cut); + + const openIds = new Set(); + for (const item of cut) { + if (item.mode !== "cluster") { + openIds.add(item.clusterId); + } + } + + const depthOf = (id: ClusterId): number => { + let depth = 0; + let node = this.#clusterTree.get(id); + while (node?.parent) { + if (openIds.has(node.parent.id)) { + depth++; + } + node = node.parent; + } + return depth; + }; + + for (const item of cut) { + if (item.mode === "cluster") { + if (openIds.has(item.clusterId)) { + continue; + } + const cluster = this.#clusterTree.get(item.clusterId); + if (cluster) { + rendered.push({ node: cluster, depth: 0 }); + } + } else if (item.mode === "children") { + const parent = this.#clusterTree.get(item.clusterId); + if (parent) { + rendered.push({ node: parent, depth: depthOf(item.clusterId) + 1 }); + this.#ensureChildrenLayout(parent); + activeLayouts.add(parent.id); + for (const child of parent.children) { + if (!openIds.has(child.id)) { + rendered.push({ node: child, depth: 0 }); + } + } + } + } else { + // "entities" / "entities-pending": leaf becomes a container of dots. + const cluster = this.#clusterTree.get(item.clusterId); + if (cluster) { + rendered.push({ node: cluster, depth: depthOf(item.clusterId) + 1 }); + this.#ensureEntityLayout(cluster); + activeLayouts.add(cluster.id); + } + } + } + + this.#commitRendered(rendered, activeLayouts); + + // Edge aggregation (topology). Reused unchanged by position ticks. + const cutIndex = new CutIndex(cut, this.#clusterTree, this.#typeSets); + this.#cutIndex = cutIndex; + this.#edgeFrame = this.#edgeAggregator.update( + cutIndex, + this.#links, + this.#typeSets, + this.#types, + this.config, + ); + + // Ports as constraints: pull each opened container's children toward the + // external neighbours they connect to (fixed rim anchors + child links). + this.#applyPortConstraints(); + + this.#emitStructure(this.#buildEntityLayers(cutIndex)); + this.#emitPositions(); + } + + /** + * Commit the flat-tier frame: the whole entity set as one individual-entity + * graph (no cluster tree). Builds/updates the single flat layout (warm-seeded, + * so streamed nodes are absorbed without a reshuffle), then emits the per-node + * style payload. Positions stream via the shared buffer, so the paired positions + * frame is trivial (it exists only to land the deferred structureVersion bump). + */ + #commitFlat(): void { + const entityIdxs = this.#allNodeEntityIdxs(); + + if (entityIdxs.length === 0) { + this.#tearDownFlat(); + this.#rendered = []; + this.#renderedIndex.clear(); + this.#emitStructure([]); + this.#emitPositions(); + return; + } + + // Warm-absorb new nodes (community-force / FA2 only: additions, same engine, + // no removal), appending into the shared buffer's spare capacity when they fit, + // or swapping in a bigger shared buffer without losing solver state when they + // don't (both in #absorbFlatNodes). Else a full rebuild (first build, mode + // switch, the cola tier which can't absorb, or a removal). See MANIFESTO + // "True incremental". + const existing = this.#forceLayouts.get(FLAT_LAYOUT_ID); + const priorBuffer = this.#flatBuffer; + const modeChanged = this.#flatLayoutMode !== this.#mode; + + let topologyChanged = !existing; + let canAbsorb = false; + if (existing && priorBuffer) { + const idxSet = new Set(entityIdxs); + const currentIds = new Set(existing.nodeIds); + const added = entityIdxs.some((idx) => !currentIds.has(String(idx))); + const removed = existing.nodeIds.some((id) => !idxSet.has(Number(id))); + topologyChanged = + added || removed || this.#links.count !== this.#flatLinkCount; + canAbsorb = + !modeChanged && + !removed && + this.#mode === "community-force" && + typeof existing.absorb === "function"; + } + + if (!existing || modeChanged || (topologyChanged && !canAbsorb)) { + this.#rebuildFlatLayout(entityIdxs); + } else if (topologyChanged) { + this.#absorbFlatNodes(existing, entityIdxs); + } + + const layout = this.#forceLayouts.get(FLAT_LAYOUT_ID); + const buffer = this.#flatBuffer; + if (!layout || !buffer) { + this.#emitStructure([]); + this.#emitPositions(); + return; + } + + // Per-node radius + colour straight into the shared buffer, and the per-link + // render edges for the bezier emission. Both refresh every commit so colours + // track a type change even when the layout itself was not rebuilt. + this.#writeFlatStyle(layout, buffer); + this.#flatRenderEdges = this.#buildFlatRenderEdges(layout); + this.#emitFlatFrame(layout); + this.#scheduleFlatLouvainLinger(); + } + + /** + * Emit the flat structure frame (count + Louvain membership for the BubbleSets) + * + its paired positions frame. Shared by the per-commit path and the trailing + * Louvain linger. (flat-force/cola has no `communities` -> undefined, no hulls.) + */ + #emitFlatFrame(layout: LayoutSimulation): void { + this.#emitStructure([], { + layoutId: FLAT_LAYOUT_ID, + count: layout.nodes.length, + communities: layout.communities + ? Int32Array.from(layout.communities) + : undefined, + }); + this.#emitPositions(); + } + + /** + * After community-force ingests go quiet for {@link FLAT_LOUVAIN_LINGER_MS}, run + * one trailing Louvain so the BubbleSets reflect the final graph; the last batch + * may not have crossed the growth-fraction refresh threshold. Each commit resets + * the timer, so it fires only once the stream settles. Position-neutral. + */ + #scheduleFlatLouvainLinger(): void { + if (this.#mode !== "community-force") { + return; + } + if (this.#flatLingerTimer !== undefined) { + clearTimeout(this.#flatLingerTimer); + } + this.#flatLingerTimer = setTimeout(() => { + this.#flatLingerTimer = undefined; + const layout = this.#forceLayouts.get(FLAT_LAYOUT_ID); + if (layout?.refreshCommunities?.()) { + this.#emitFlatFrame(layout); + } + }, FLAT_LOUVAIN_LINGER_MS); + } + + /** + * All loaded node entities, sorted by index for a deterministic shared-buffer + * order. Link entities live in no type-set group, so iterating the groups yields + * exactly the nodes. + */ + #allNodeEntityIdxs(): EntityIdx[] { + const result: EntityIdx[] = []; + for (const group of this.#typeSets) { + for (const idx of group.entityIdxs) { + result.push(idx); + } + } + result.sort((lhs, rhs) => lhs - rhs); + return result; + } + + /** + * Publish the join-map SharedArrayBuffer to the main thread the first time (later + * re-allocations re-publish via {@link #republishEntityIdMap}). The EntityStore keeps + * the buffer's contents current on intern, so this is purely "here is the buffer to read." + */ + #publishEntityIdMapOnce(): void { + if (this.#entityIdMapPublished) { + return; + } + const map = this.#entities.entityIdMap; + this.#onLayoutMessage?.({ + type: "ENTITY_ID_MAP", + buffer: map.raw, + capacity: map.capacity, + }); + this.#entityIdMapPublished = true; + } + + /** + * (Re)build the flat layout over the given node set, warm-seeded from the + * current layout's positions so existing nodes don't move and a streamed node + * appears beside an already-placed neighbour (incremental absorb, LAYOUT-MODES + * "Incremental loading"). Replaces the shared buffer (LAYOUT_DESTROYED + LAYOUT_CREATED). + */ + #rebuildFlatLayout(entityIdxs: readonly EntityIdx[]): void { + const previous = this.#forceLayouts.get(FLAT_LAYOUT_ID); + const priorPositions = new Map(); + if (previous) { + for (const node of previous.nodes) { + priorPositions.set(Number(node.id), [node.x ?? 0, node.y ?? 0]); + } + } + + const nodes = this.#seedFlatNodes(entityIdxs, priorPositions); + const edges = this.#buildEntityEdges([...entityIdxs], nodes); + // One interleaved shared buffer for all per-node data; the layout writes + // positions into it, the worker writes radius/colour. Over-allocated past the + // live count so community-force can append streamed nodes in place (warm FA2 + // absorb: bump count + version, no realloc); overflowing the headroom rebuilds here. + const buffer = new FlatGraphBuffer( + flatCapacityFor(nodes.length), + this.#republishFlatBuffer, + ); + buffer.setCount(nodes.length); + // flat-force uses cola (best layout for small N); community-force uses the + // FA2 pipeline (Louvain -> SMACOF seed -> FA2) that scales past cola's O(N²). + // Both fill the same shared buffer, so everything downstream (style, edges, + // render) is identical; the tiers differ only in engine and (next) BubbleSets. + const layout = + this.#mode === "community-force" + ? createCommunityLayout(nodes, edges, buffer, this.config.fa2) + : createFlatLayout(nodes, edges, buffer); + + if (previous) { + this.#onLayoutMessage?.({ + type: "LAYOUT_DESTROYED", + clusterId: FLAT_LAYOUT_ID, + }); + } + + this.#flatBuffer = buffer; + this.#flatLinkCount = this.#links.count; + this.#flatLayoutMode = this.#mode; + this.#forceLayouts.set(FLAT_LAYOUT_ID, layout); + this.#layoutKind.set(FLAT_LAYOUT_ID, "entities"); + this.#ensureSchedulerRunning(); + + this.#onLayoutMessage?.({ + type: "LAYOUT_CREATED", + clusterId: FLAT_LAYOUT_ID, + buffer: buffer.raw, + nodeIds: layout.nodeIds, + flatCapacity: buffer.capacity, + }); + } + + /** + * Warm-absorb newly-arrived nodes into the live community-force (FA2) layout: + * seed them against the layout's current positions (existing nodes echo where + * they are; new nodes land beside their placed neighbours), then hand the layout + * the new nodes + the full edge set. The shared buffer was over-allocated, so the + * appended records land in spare capacity and {@link #writeFlatStyle}'s count + + * version bump publishes them in one atomic sync: no buffer realloc, no + * LAYOUT_CREATED, no cold restart (the growable-shared-buffer win). community-force + * only; {@link #commitFlat} gates everything else to {@link #rebuildFlatLayout}. + */ + #absorbFlatNodes( + layout: LayoutSimulation, + entityIdxs: readonly EntityIdx[], + ): void { + const priorPositions = new Map(); + for (const node of layout.nodes) { + priorPositions.set(Number(node.id), [node.x ?? 0, node.y ?? 0]); + } + const seeded = this.#seedFlatNodes(entityIdxs, priorPositions); + const currentIds = new Set(layout.nodeIds); + const newNodes = seeded.filter((node) => !currentIds.has(node.id)); + const edges = this.#buildEntityEdges([...entityIdxs], seeded); + + // Within capacity, appended records land in spare shared-buffer space: no realloc, no + // re-send (#writeFlatStyle's count + version bump publishes them in one atomic sync). + // Over it, ensureCapacity re-allocates a bigger buffer (the flat shared buffer is + // non-resizable for GPU upload, so it can't grow in place), copying the records across; + // the layout holds the same FlatGraphBuffer instance, so its warm FA2 state rides across + // (only the instance's raw is swapped under it), and #republishFlatBuffer hands the new + // buffer to the main thread. No cold restart either way. + if (this.#flatBuffer && entityIdxs.length > this.#flatBuffer.capacity) { + this.#flatBuffer.ensureCapacity(flatCapacityFor(entityIdxs.length)); + } + + layout.absorb?.(newNodes, edges); + this.#flatLinkCount = this.#links.count; + // absorb() re-energises the layout (status -> running), but the scheduler may + // have stopped when it last settled, so re-kick it, or the new nodes sit frozen + // at their seed (the "added but no more ticks, edges stay long" symptom). + this.#ensureSchedulerRunning(); + } + + /** + * Seed positions for a flat (re)build: keep every already-placed node where it + * is; place a new node beside a placed neighbour (deterministic offset); and + * fall back to a phyllotaxis disk for nodes with no placed neighbour (cold + * start, or a new orphan). WebCola majorises from this warm seed, so the layout + * absorbs streamed nodes instead of reshuffling. + */ + #seedFlatNodes( + entityIdxs: readonly EntityIdx[], + priorPositions: ReadonlyMap, + ): ForceNode[] { + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const placed = new Map(); + for (const idx of entityIdxs) { + const prior = priorPositions.get(idx); + if (prior) { + placed.set(idx, [prior[0], prior[1]]); + } + } + + // Grow placement outward from placed nodes along their links. + let changed = true; + while (changed) { + changed = false; + for (const idx of entityIdxs) { + if (placed.has(idx)) { + continue; + } + for (const link of this.#links.linksForEntity(idx)) { + const neighbour = placed.get(link.otherIdx as number); + if (neighbour) { + const angle = idx * goldenAngle; + placed.set(idx, [ + neighbour[0] + Math.cos(angle) * FLAT_SEED_NEIGHBOUR_OFFSET, + neighbour[1] + Math.sin(angle) * FLAT_SEED_NEIGHBOUR_OFFSET, + ]); + changed = true; + break; + } + } + } + } + + // Remaining unplaced -> a phyllotaxis disk (even, deterministic fill). + const unplaced = entityIdxs.filter((idx) => !placed.has(idx)); + const fillRadius = + FLAT_SEED_DISK_SCALE * Math.sqrt(Math.max(1, unplaced.length)); + for (let slot = 0; slot < unplaced.length; slot++) { + const dist = fillRadius * Math.sqrt((slot + 0.5) / unplaced.length); + const angle = slot * goldenAngle; + placed.set(unplaced[slot]!, [ + Math.cos(angle) * dist, + Math.sin(angle) * dist, + ]); + } + + return entityIdxs.map((idx) => { + const position = placed.get(idx)!; + const degree = this.#links.linksForEntity(idx).length; + return { + id: String(idx), + x: position[0], + y: position[1], + radius: radiusForDegree(degree), + }; + }); + } + + /** + * Write per-node radius + colour into the interleaved shared buffer (one record + * per node), set the live count, and publish. Runs each commit, so a type change + * recolours in place without a structure round-trip. + */ + #writeFlatStyle(layout: LayoutSimulation, buffer: FlatGraphBuffer): void { + const colorByGroup = new Map(); + for (let idx = 0; idx < layout.nodes.length; idx++) { + const node = layout.nodes[idx]!; + const entityIdx = Number(node.id) as EntityIdx; + buffer.setRadius(idx, node.radius); + const base = this.#colorForEntity(entityIdx, colorByGroup); + const dimmed = + this.#highlightedEntities.size > 0 && + !this.#highlightedEntities.has(entityIdx); + buffer.setColor(idx, dimmed ? dimColor(base) : base); + // The join key: which entity this record is (the main thread pairs it with + // the EntityId map shared buffer to resolve labels / icons / tooltips / picking). + buffer.setEntityIdx(idx, entityIdx); + } + buffer.setCount(layout.nodes.length); + buffer.commit(); + } + + /** + * Build the flat render edges (one per link, both endpoints in the node set): + * local node indices + the link's own type colour. Rebuilt each commit so + * colours track type changes; the per-tick geometry reads the two nodes' + * current positions for each. + */ + #buildFlatRenderEdges(layout: LayoutSimulation): FlatRenderEdge[] { + const localOf = new Map(); + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + localOf.set(Number(layout.nodeIds[idx]), idx); + } + const colorCache = new Map(); + const seenLinks = new Set(); + const edges: FlatRenderEdge[] = []; + for (const [entityIdx, sourceIdx] of localOf) { + for (const link of this.#links.linksForEntity(entityIdx as EntityIdx)) { + if (seenLinks.has(link.linkIdx)) { + continue; + } + const targetIdx = localOf.get(link.otherIdx as number); + if (targetIdx === undefined) { + continue; + } + seenLinks.add(link.linkIdx); + edges.push({ + sourceIdx: link.direction === "out" ? sourceIdx : targetIdx, + targetIdx: link.direction === "out" ? targetIdx : sourceIdx, + color: this.#edgeColorForTypeGroup(link.typeSetIdx, colorCache), + linkEntityIdx: this.#links.getEntityIdx(link.linkIdx), + }); + } + } + return edges; + } + + /** + * Fill the sink with one straight cubic per flat render lane from the nodes' + * current positions, coloured by link type, width in world units. Runs each + * position tick so the edges track the dots as the layout settles. + */ + #buildFlatEdgeBeziers( + sink: BezierSegmentSink, + arrowsOut: RenderEdgeArrow[], + ): void { + const layout = this.#forceLayouts.get(FLAT_LAYOUT_ID); + if (!layout) { + return; + } + const { nodes } = layout; + for (const edge of this.#flatRenderEdges) { + const source = nodes[edge.sourceIdx]; + const target = nodes[edge.targetIdx]; + if (!source || !target) { + continue; + } + const ax = source.x ?? 0; + const ay = source.y ?? 0; + const bx = target.x ?? 0; + const by = target.y ?? 0; + const dx = bx - ax; + const dy = by - ay; + const chord = Math.hypot(dx, dy); + if (chord <= 0.001) { + continue; + } + const startDistance = source.radius + FLAT_EDGE_WIDTH_WORLD; + const endDistance = target.radius + FLAT_EDGE_WIDTH_WORLD; + const visibleChord = chord - startDistance - endDistance; + if (visibleChord <= FLAT_EDGE_WIDTH_WORLD) { + continue; + } + const ux = dx / chord; + const uy = dy / chord; + const sx = ax + ux * startDistance; + const sy = ay + uy * startDistance; + const tx = bx - ux * endDistance; + const ty = by - uy * endDistance; + const edgeEndInset = Math.min( + FLAT_EDGE_WIDTH_WORLD * 0.9, + visibleChord * 0.35, + ); + const edgeTx = tx - ux * edgeEndInset; + const edgeTy = ty - uy * edgeEndInset; + const visibleDx = edgeTx - sx; + const visibleDy = edgeTy - sy; + // An edge stays full only when BOTH endpoints are highlighted; otherwise it dims with + // the field. (No highlight active -> every edge is full.) + const highlighted = this.#highlightedEntities; + const full = + highlighted.size === 0 || + (highlighted.has(Number(source.id) as EntityIdx) && + highlighted.has(Number(target.id) as EntityIdx)); + const color = full ? edge.color : dimColor(edge.color); + sink.push( + { + p0: [sx, sy], + p1: [sx + visibleDx / 3, sy + visibleDy / 3], + p2: [sx + (2 * visibleDx) / 3, sy + (2 * visibleDy) / 3], + p3: [edgeTx, edgeTy], + }, + color, + FLAT_EDGE_WIDTH_WORLD, + undefined, + undefined, + edge.linkEntityIdx, + ); + const arrowSize = FLAT_EDGE_WIDTH_WORLD; + const arrowInset = Math.min( + FLAT_EDGE_WIDTH_WORLD * 0.45, + visibleChord * 0.2, + ); + arrowsOut.push({ + kind: "endpoint", + x: tx + ux * arrowInset, + y: ty + uy * arrowInset, + angle: Math.atan2(dy, dx), + size: arrowSize, + color, + chord: visibleChord, + }); + } + } + + /** Hierarchy-aware colour for an entity, cached per type-set group. */ + #colorForEntity(entityIdx: EntityIdx, cache: Map): Color { + // A frontier node (fetched, not yet expanded) reads greyed-out, whatever its type. + if (!this.#entities.isRoot(entityIdx)) { + return FRONTIER_COLOR; + } + const groupIdx = this.#entities.getTypeGroup(entityIdx); + if (groupIdx === -1) { + return colorForType(undefined, this.#types); + } + return this.#colorForTypeGroup(groupIdx, cache); + } + + /** Hierarchy-aware NODE colour for a type-set group, cached. Keys off the type's ROOT (a family + * shares a hue) -- see {@link colorForType}. */ + #colorForTypeGroup( + groupIdx: TypeSetIdx, + cache: Map, + ): Color { + const cached = cache.get(groupIdx); + if (cached) { + return cached; + } + const group = this.#typeSets.getByIdx(groupIdx); + const primary = group + ? primaryTypeOfSet(group.directTypeIdxs, this.#types) + : undefined; + const color = colorForType(primary, this.#types); + cache.set(groupIdx, color); + return color; + } + + /** EDGE colour for a link's type-set group, cached. Keys off the link's primary DIRECT type's OWN + * slot, NOT its root -- every link type shares the `Link` root, so root-colouring collapses all + * edges to one hue (see {@link edgeColorForType}). */ + #edgeColorForTypeGroup( + groupIdx: TypeSetIdx, + cache: Map, + ): Color { + const cached = cache.get(groupIdx); + if (cached) { + return cached; + } + const group = this.#typeSets.getByIdx(groupIdx); + const primary = group + ? primaryTypeOfSet(group.directTypeIdxs, this.#types) + : undefined; + const color = edgeColorForType(primary, this.#types); + cache.set(groupIdx, color); + return color; + } + + /** Destroy the flat layout + its shared buffer (entering the hierarchical regime). */ + #tearDownFlat(): void { + if (this.#forceLayouts.has(FLAT_LAYOUT_ID)) { + this.#forceLayouts.delete(FLAT_LAYOUT_ID); + this.#layoutKind.delete(FLAT_LAYOUT_ID); + this.#onLayoutMessage?.({ + type: "LAYOUT_DESTROYED", + clusterId: FLAT_LAYOUT_ID, + }); + } + this.#flatBuffer = undefined; + this.#flatRenderEdges = []; + this.#flatLinkCount = -1; + this.#flatLayoutMode = undefined; + if (this.#flatLingerTimer !== undefined) { + clearTimeout(this.#flatLingerTimer); + this.#flatLingerTimer = undefined; + } + } + + /** + * Destroy all hierarchical layouts + reset the render/edge state (entering the + * flat regime). The cluster tree is left to be rebuilt fresh on the next + * hierarchical commit (it reads the always-current type sets), so flat mode + * spends no time clearing it. + */ + #tearDownHierarchical(): void { + for (const [id, kind] of this.#layoutKind) { + if (kind === "entities") { + this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId: id }); + } + } + this.#forceLayouts.clear(); + this.#layoutKind.clear(); + this.#entityPortTargets.clear(); + this.#anchorEndpoints.clear(); + this.#clusterEdges.clear(); + this.#untangled.clear(); + this.#topLevelPositions.clear(); + this.#portCache.clear(); + this.#edgeAggregator.reset(); + this.#cutIndex = undefined; + this.#edgeFrame = undefined; + this.#rendered = []; + this.#renderedIndex.clear(); + } + + /** Replace the committed visible set and destroy layouts that left the cut. */ + #commitRendered( + rendered: RenderedEntry[], + activeLayouts: ReadonlySet, + ): void { + this.#rendered = rendered; + this.#renderedIndex.clear(); + for (let idx = 0; idx < rendered.length; idx++) { + this.#renderedIndex.set(rendered[idx]!.node.id, idx); + } + + for (const key of this.#forceLayouts.keys()) { + if (!activeLayouts.has(key)) { + const wasEntity = this.#layoutKind.get(key) === "entities"; + this.#forceLayouts.delete(key); + this.#layoutKind.delete(key); + this.#entityPortTargets.delete(key); + this.#anchorEndpoints.delete(key); + this.#clusterEdges.delete(key); + this.#untangled.delete(key); + if (wasEntity) { + this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId: key }); + } + } + } + } + + #emitStructure( + entityLayers: readonly RenderEntityLayer[], + flatGraph?: RenderFlatGraph, + ): void { + this.#structureVersion++; + const clusters: RenderCluster[] = this.#rendered.map((entry) => + this.#renderCluster(entry.node, entry.depth), + ); + if (this.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][structure v${this.#structureVersion}] mode=${this.#mode} ` + + `clusters=${clusters.length} entityLayers=${entityLayers.length} ` + + `flat=${flatGraph?.count ?? 0} ` + + `layouts=${this.#forceLayouts.size}`, + ); + } + this.#onStructureFrame?.({ + version: this.#structureVersion, + mode: this.#mode, + clusters, + entityLayers, + flatGraph, + highwayLanes: this.#buildHighwayLanes(), + }); + } + + /** + * Per-lane summaries for the rendered highways, indexed by `laneId` (a visual + * edge's index in the current edge frame, which is also the `id` on the lane's + * bezier segments). Individual (non-aggregate) edges fill their slot with a + * placeholder so the index stays aligned with `laneId`. + */ + #buildHighwayLanes(): HighwayLaneSummary[] { + const placeholder: HighwayLaneSummary = { + typeId: null, + typeLabel: "", + count: 0, + direction: "both", + }; + const edges = this.#edgeFrame?.visualEdges; + if (!edges) { + this.#highwayLaneUnions = []; + return []; + } + // A merged highway renders several aggregate lanes (different children/types) as ONE + // ribbon whose segments carry a single representative laneId. Group lanes by their + // highway-level endpoints -- the SAME analyzeHierarchy the renderer merges by -- so any + // segment of a ribbon resolves to the WHOLE ribbon's links + a combined summary. A direct + // (unmerged) lane is its own group, so it stays exact. + const containerIds = this.#cutIndex?.containerIds ?? new Set(); + const groups = new Map(); + for (let idx = 0; idx < edges.length; idx++) { + const edge = edges[idx]!; + if (edge.kind !== "aggregate") { + continue; + } + const { sourceContainers, targetContainers } = analyzeHierarchy( + edge.source.id, + edge.target.id, + this.#clusterTree, + containerIds, + ); + // A lane is single-type+direction. A merged highway collapses the SAME type+direction + // across several children into one ribbon, so the group key is (outer endpoints, type, + // direction) -- NOT endpoints alone, which would fold distinct single-type lanes together. + // A direct (unmerged) lane keys on its own visualKey (already per type+direction). + const outerSource = + sourceContainers[sourceContainers.length - 1]?.containerId ?? + edge.source.id; + const outerTarget = + targetContainers[targetContainers.length - 1]?.containerId ?? + edge.target.id; + const key = + sourceContainers.length === 0 && targetContainers.length === 0 + ? (edge.visualKey as string) + : `hw:${outerSource}:${outerTarget}:${edge.typeSetIdx ?? "roll"}:${edge.direction}`; + const list = groups.get(key); + if (list) { + list.push(idx); + } else { + groups.set(key, [idx]); + } + } + + const summaries: HighwayLaneSummary[] = edges.map(() => placeholder); + const unions: EntityIdx[][] = edges.map(() => []); + for (const laneIdxs of groups.values()) { + const union = new Set(); + let typeId: VersionedUrl | null = null; + let typeLabel = ""; + let direction: HighwayLaneSummary["direction"] = "both"; + let count = 0; + for (const idx of laneIdxs) { + const edge = edges[idx]; + if (!edge || edge.kind !== "aggregate") { + continue; + } + for (const entityIdx of edge.linkEntityIdxs) { + union.add(entityIdx); + } + count += edge.count; + // Every lane in a group shares one type + direction (the group key), so any member's + // identity describes the whole group. + typeId = edge.typeId; + typeLabel = edge.typeLabel; + direction = edge.direction; + } + const summary: HighwayLaneSummary = { + typeId, + typeLabel, + count, + direction, + }; + const unionArr = [...union]; + for (const idx of laneIdxs) { + summaries[idx] = summary; + unions[idx] = unionArr; + } + } + this.#highwayLaneUnions = unions; + return summaries; + } + + /** + * The link entities a clicked highway represents: the UNION of every aggregate lane that + * merged into the same rendered ribbon as `laneId` (so a merged highway opens ALL its links, + * not just the representative lane's). Precomputed per structure by {@link #buildHighwayLanes}; + * empty for an out-of-range / non-aggregate id. + */ + highwayLinks(laneId: number): EntityIdx[] { + return laneId >= 0 && laneId < this.#highwayLaneUnions.length + ? [...(this.#highwayLaneUnions[laneId] ?? [])] + : []; + } + + /** + * Recompute ports at the current positions. Ports live at the highway level: + * each base pair is collapsed to its outermost rendered containers, so a + * cluster's port toward a neighbor's subtree is stable whether that subtree's + * container is open or closed (opening an unrelated container no longer + * reshuffles a cluster's ports). + */ + #computePorts(): PortPairs { + if (!this.#edgeFrame || !this.#cutIndex) { + return new Map(); + } + const containerIds = this.#cutIndex.containerIds; + const highwayPairs = new Map< + string, + { + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + totalCount: number; + readonly byType: Map; + } + >(); + + for (const pair of this.#edgeAggregator.pairs.values()) { + const { hwSourceId, hwTargetId } = highwayEndpoints( + pair.sourceId, + pair.targetId, + this.#clusterTree, + containerIds, + ); + if (hwSourceId === hwTargetId) { + continue; + } + const { key, sourceId, targetId } = makePairKey(hwSourceId, hwTargetId); + let highway = highwayPairs.get(key); + if (!highway) { + highway = { sourceId, targetId, totalCount: 0, byType: new Map() }; + highwayPairs.set(key, highway); + } + highway.totalCount += pair.totalCount; + for (const typeIdx of pair.byType.keys()) { + highway.byType.set(typeIdx, true); + } + } + + return computeAllPorts( + highwayPairs, + this.#clusterTree, + this.config, + this.#portCache, + ); + } + + /** + * Emit a PositionsFrame: bounded cluster positions plus freshly-computed + * highway/feeder Bezier geometry for the current positions. No aggregation. + */ + #emitPositions(): void { + // Authoritative: recompose the opened subtree's world circles before any + // positional read, so a moved ancestor reaches its whole subtree (incl. + // settled depth >= 2 layouts), on commit ticks too, not only while moving. + this.#syncWorldPositions(); + const ports = this.#computePorts(); + // Labels are emitted by the geometry builder at true curve midpoints, so + // they sit on the drawn highways (one per merged highway, not per base pair). + const edgeLabels: RenderEdgeLabel[] = []; + const edgeArrows: RenderEdgeArrow[] = []; + + this.#bezierSink.reset(); + if (this.#edgeFrame && this.#cutIndex) { + // Every visible bubble (including opened containers) is a potential + // obstacle; routeAround exempts the ones that enclose an edge's endpoint. + const obstacles = this.#rendered.map((entry) => ({ + id: entry.node.id, + circle: entry.node.circle, + })); + buildBezierSegments( + this.#edgeFrame, + ports, + { clusterTree: this.#clusterTree, cutIndex: this.#cutIndex, obstacles }, + this.config, + this.#bezierSink, + edgeLabels, + edgeArrows, + ); + } else if (this.#flatRenderEdges.length > 0) { + // Flat tier: straight, clipped segments from current node positions. The + // renderer uses LineLayer for these instead of the hierarchical Bezier SDF. + this.#buildFlatEdgeBeziers(this.#bezierSink, edgeArrows); + } + const beziers = this.#bezierSink.snapshot(); + + const clusterPositions = new Float32Array(this.#rendered.length * 2); + for (let idx = 0; idx < this.#rendered.length; idx++) { + const circle = this.#rendered[idx]!.node.circle; + clusterPositions[idx * 2] = circle.x; + clusterPositions[idx * 2 + 1] = circle.y; + } + + // Fan-out feeder endpoints + force targets for the current positions + // (positional: refreshed every tick, never via the structure frame). + const entityFanOut = + this.#cutIndex && this.#edgeFrame + ? this.#buildEntityFanOut(this.#cutIndex, ports) + : []; + + this.#positionVersion++; + this.#onPositionsFrame?.({ + version: this.#positionVersion, + settled: !this.#anyClusterLayoutRunning(), + clusterPositions, + beziers, + edgeLabels, + edgeArrows, + entityFanOut, + }); + } + + #renderCluster(cluster: ClusterNode, depth: number): RenderCluster { + const frontierIdxs = this.#frontierMembers(cluster); + const allFrontier = + cluster.count > 0 && frontierIdxs.length === cluster.count; + // Multi-line property labels (newline-joined "Title = value" lines) get the count on its + // own line below the stack; a single-line type-set label keeps it inline as before. + const text = cluster.label.text; + const label = + text.length === 0 + ? `(${cluster.count})` + : text.includes("\n") + ? `${text}\n(${cluster.count})` + : `${text} (${cluster.count})`; + return { + id: cluster.id, + color: colorForCluster(cluster, this.#types), + label, + count: cluster.count, + radius: cluster.circle.radius, + depth, + frontierCount: frontierIdxs.length, + ...(allFrontier + ? { + frontierEntityIds: frontierIdxs.map((idx) => + this.#entities.getEntityId(idx), + ), + } + : {}), + }; + } + + /** + * The frontier (non-root) members of a cluster, as EntityIdxs. Members come + * from a direct index column or, for a type-keyed cluster, the union of its + * type-set groups (see {@link ClusterNode.membership}). + */ + #frontierMembers(cluster: ClusterNode): EntityIdx[] { + const frontier: EntityIdx[] = []; + const { membership } = cluster; + if (membership.source === "groups") { + for (const key of membership.keys) { + const group = this.#typeSets.get(key); + if (!group) { + continue; + } + for (const entityIdx of group.entityIdxs) { + if (!this.#entities.isRoot(entityIdx)) { + frontier.push(entityIdx); + } + } + } + return frontier; + } + + const members = membership.members.subarray(); + for (let idx = 0; idx < members.length; idx++) { + const entityIdx = members.get(idx); + if (!this.#entities.isRoot(entityIdx)) { + frontier.push(entityIdx); + } + } + return frontier; + } + + /** + * Build per-leaf entity-edge topology for the structure frame: the internal + * entity-to-entity links (drawn from the shared buffer) plus the stable per-leaf + * style. Fan-out feeder endpoints are positional and ride the PositionsFrame instead + * (see {@link #buildEntityFanOut}), so this runs only on a cut change. + */ + #buildEntityLayers(cutIndex: CutIndex): RenderEntityLayer[] { + const layers: RenderEntityLayer[] = []; + + for (const leafId of cutIndex.entityModeIds) { + const leafIndex = this.#renderedIndex.get(leafId); + const cluster = this.#clusterTree.get(leafId); + const layout = this.#forceLayouts.get(leafId); + if (leafIndex === undefined || !cluster || !layout) { + continue; + } + + const localOf = new Map(); + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + localOf.set(Number(layout.nodeIds[idx]), idx); + } + + // Internal entity-to-entity links (both endpoints owned by this leaf). + const internal: number[] = []; + if (this.#edgeFrame) { + for (const edge of this.#edgeFrame.visualEdges) { + if ( + edge.kind !== "individual" || + edge.source.ownerClusterId !== leafId + ) { + continue; + } + const a = localOf.get(edge.source.entityIdx); + const b = localOf.get(edge.target.entityIdx); + if (a !== undefined && b !== undefined) { + internal.push(a, b); + } + } + } + + layers.push({ + layoutId: leafId, + leafClusterIndex: leafIndex, + count: layout.nodeIds.length, + radius: cluster.circle.radius * ENTITY_RADIUS_FRACTION, + color: colorForCluster(cluster, this.#types), + internalEdges: Uint32Array.from(internal), + fanOutColor: FAN_OUT_COLOR, + }); + } + + return layers; + } + + // Write each leaf node's colour into its interleaved entity buffer, so the dots render + // per-node colour. Every node gets the leaf's cluster colour; an active highlight dims the + // non-ego nodes. Called on leaf-buffer creation and on highlight change only -- NOT per + // commit (that re-uploaded every open leaf on each zoom LOD crossing: a pan/zoom stutter). + #writeLeafColors(cluster: ClusterNode, layout: LayoutSimulation): void { + if (!layout.setNodeColor) { + return; + } + const base = colorForCluster(cluster, this.#types); + const dim = dimColor(base); + const highlighted = this.#highlightedEntities; + const active = highlighted.size > 0; + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + const entityIdx = Number(layout.nodeIds[idx]) as EntityIdx; + // A frontier node reads greyed-out, overriding the cluster colour and the focus dim. + if (!this.#entities.isRoot(entityIdx)) { + layout.setNodeColor(idx, FRONTIER_COLOR); + continue; + } + const dimmed = active && !highlighted.has(entityIdx); + layout.setNodeColor(idx, dimmed ? dim : base); + } + layout.commitColors?.(); + } + + /** + * Fan-out feeder endpoints for the current positions (one entry per open + * leaf), and a refill of each leaf's port-attraction targets. Positional: it + * runs every position tick and rides the PositionsFrame, so the dots' exits + * (and the force pulling the dots toward them) track the ports as the macro + * layout settles, without re-emitting the structure. Per external owner, the + * exit is the leaf's own boundary point toward the highway port serving it, so + * the fan-out chains into the feeder -> highway. + */ + #buildEntityFanOut( + cutIndex: CutIndex, + ports: PortPairs, + ): RenderEntityFanOut[] { + const result: RenderEntityFanOut[] = []; + // While the macro is still moving, the ports drift continuously, so keep every + // dot layout warm so the dots track that drift instead of lagging it. (The + // old ">0.5 since last refill" threshold silently dropped slow drift: many + // sub-threshold steps accumulated into a large offset that never re-settled, + // the depth >= 2 "port at the old position" the macro's slow tail produced.) + const clustersRunning = this.#anyClusterLayoutRunning(); + + for (const leafId of cutIndex.entityModeIds) { + const cluster = this.#clusterTree.get(leafId); + const layout = this.#forceLayouts.get(leafId); + if (!cluster || !layout) { + continue; + } + + const localOf = new Map(); + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + localOf.set(Number(layout.nodeIds[idx]), idx); + } + + const exitForOwner = new Map< + ClusterId, + readonly [number, number] | null + >(); + const ownerExit = ( + otherOwner: ClusterId, + ): readonly [number, number] | null => { + const cached = exitForOwner.get(otherOwner); + if (cached !== undefined) { + return cached; + } + const { hwSourceId, hwTargetId } = highwayEndpoints( + leafId, + otherOwner, + this.#clusterTree, + cutIndex.containerIds, + ); + const hp = + hwSourceId === hwTargetId + ? undefined + : portsFor(ports, hwSourceId, hwTargetId); + let exit: readonly [number, number] | null = null; + if (hp) { + // Aim the exit at the feeder's first waypoint, not at the outermost + // port directly. The feeder leaves this leaf toward its nearest + // enclosing open container's boundary (in the direction of the + // outermost port hp.a), then hops outward. Those coincide only when the + // leaf sits directly in the outermost container (depth 1); with an + // intermediate container (depth >= 2) aiming straight at hp.a lands the + // fan-out a few position-dependent degrees off where the feeder + // actually leaves the bucket, the "4 vs 5 o'clock" drift. Share the + // exact waypoint fn the feeder uses so the two can never diverge. + let target: { readonly x: number; readonly y: number } = hp.a; + let ancestor = cluster.parent; + while (ancestor) { + if (cutIndex.containerIds.has(ancestor.id)) { + if (ancestor.id !== hwSourceId) { + target = containerBoundaryWaypoint( + ancestor.circle, + hp.a.x, + hp.a.y, + this.config.portPaddingWorld, + ); + } + break; + } + ancestor = ancestor.parent; + } + const angle = Math.atan2( + target.y - cluster.circle.y, + target.x - cluster.circle.x, + ); + exit = [ + cluster.circle.radius * Math.cos(angle), + cluster.circle.radius * Math.sin(angle), + ]; + } + exitForOwner.set(otherOwner, exit); + return exit; + }; + + const portTargets = this.#entityPortTargets.get(leafId); + // A dot gaining or losing an external connection (e.g. on reopen, or a + // highway re-routing) must re-energise the sim even when the macro is + // settled: a structural change, not continuous drift. + let connectivityChanged = false; + const fanOut: number[] = []; + for (const node of layout.nodes) { + const entityIdx = Number(node.id) as EntityIdx; + const localIdx = localOf.get(entityIdx)!; + const seenTargets = new Set(); + let sumX = 0; + let sumY = 0; + let exitCount = 0; + for (const link of this.#links.linksForEntity(entityIdx)) { + const otherOwner = cutIndex.ownerOf(link.otherIdx as number); + if ( + !otherOwner || + otherOwner === leafId || + seenTargets.has(otherOwner) + ) { + continue; + } + seenTargets.add(otherOwner); + const exit = ownerExit(otherOwner); + if (exit) { + fanOut.push(localIdx, exit[0], exit[1]); + sumX += exit[0]; + sumY += exit[1]; + exitCount += 1; + } + } + // Port-attraction target: the centroid of this entity's exits (NaN if it + // has no external connection). The entity layout's force reads this live, + // so the dots cluster near their ports instead of fanning across. + if (portTargets) { + const hasTarget = exitCount > 0; + const nextX = hasTarget ? sumX / exitCount : Number.NaN; + const nextY = hasTarget ? sumY / exitCount : Number.NaN; + const hadTarget = !Number.isNaN(portTargets[localIdx * 2]!); + if (hadTarget !== hasTarget) { + connectivityChanged = true; + } + portTargets[localIdx * 2] = nextX; + portTargets[localIdx * 2 + 1] = nextY; + } + } + + // Re-energise the (possibly settled) entity sim so the dots reach their + // ports. While the macro moves, the targets drift every tick, so track it; + // a connectivity flip is a one-off structural change. A still-running sim + // is a no-op. Once the macro settles, the warm sim relaxes onto the now- + // fixed targets, so the dots end up at their ports, not lagging the tail. + if (clustersRunning || connectivityChanged) { + layout.resume(); + } + + result.push({ layoutId: leafId, fanOut: Float32Array.from(fanOut) }); + } + + return result; + } + + /** + * Ports as WebCola constraints. For each opened container, add a fixed anchor + * on its rim toward each external neighbour and link the children whose edges + * cross it, so the layout sorts children toward their real connections and + * feeders leave the container without crossing. Applied only while the layout + * is still running (a settled, reused layout is left alone, no re-settle). + */ + #applyPortConstraints(): void { + const edgeFrame = this.#edgeFrame; + const cutIndex = this.#cutIndex; + if (!edgeFrame || !cutIndex) { + return; + } + + for (const [containerId, layout] of this.#forceLayouts) { + if ( + this.#layoutKind.get(containerId) !== "clusters" || + layout.status !== "running" || + !layout.setPortAnchors + ) { + continue; + } + const container = this.#clusterTree.get(containerId); + if (!container || container.kind === "root") { + continue; + } + + const childIndex = new Map(); + for (const [idx, child] of container.children.entries()) { + childIndex.set(child.id, idx); + } + + // Group the children by the external endpoint they connect to; the anchor + // sits on the rim in that endpoint's direction. + const byEndpoint = new Map< + ClusterId, + { x: number; y: number; counts: Map } + >(); + for (const edge of edgeFrame.visualEdges) { + if (edge.kind !== "aggregate") { + continue; + } + const sourceInside = childIndex.has(edge.source.id); + const targetInside = childIndex.has(edge.target.id); + if (sourceInside === targetInside) { + continue; // both internal, or both external, not a boundary edge + } + const childId = sourceInside ? edge.source.id : edge.target.id; + const externalId = sourceInside ? edge.target.id : edge.source.id; + const { hwTargetId } = highwayEndpoints( + childId, + externalId, + this.#clusterTree, + cutIndex.containerIds, + ); + const endpoint = this.#clusterTree.get(hwTargetId); + if (!endpoint) { + continue; + } + const dx = endpoint.circle.x - container.circle.x; + const dy = endpoint.circle.y - container.circle.y; + const dist = Math.hypot(dx, dy); + if (dist < 1e-6) { + continue; + } + let anchor = byEndpoint.get(hwTargetId); + if (!anchor) { + anchor = { + x: (dx / dist) * container.circle.radius, + y: (dy / dist) * container.circle.radius, + counts: new Map(), + }; + byEndpoint.set(hwTargetId, anchor); + } + const childPos = childIndex.get(childId)!; + anchor.counts.set( + childPos, + (anchor.counts.get(childPos) ?? 0) + edge.count, + ); + } + + if (byEndpoint.size > 0) { + const anchorList: PortAnchor[] = []; + for (const anchor of byEndpoint.values()) { + anchorList.push({ + x: anchor.x, + y: anchor.y, + // Pull weight grows (log) with the edge count through this port, so + // a child's strongest connection wins its placement. + children: [...anchor.counts].map(([index, count]) => ({ + index, + weight: 1 + Math.log2(1 + count), + })), + }); + } + layout.setPortAnchors(anchorList); + // Remember the endpoints (in anchor order) so #updateAnchorTracking can + // re-aim the anchors as the macro moves. + this.#anchorEndpoints.set(containerId, [...byEndpoint.keys()]); + } else { + this.#anchorEndpoints.delete(containerId); + } + } + } + + /** + * Re-aim opened sub-clusters' port anchors at their (now-moved) external + * neighbours, moving the fixed anchors in place: no re-run, no structure + * emit. Light (a few anchors per opened container). Called when the macro + * layout moves; a still-running sub-cluster's children follow. + */ + #updateAnchorTracking(): void { + for (const [containerId, endpointIds] of this.#anchorEndpoints) { + const layout = this.#forceLayouts.get(containerId); + const container = this.#clusterTree.get(containerId); + if (!layout?.updateAnchorPositions || !container) { + continue; + } + const positions = endpointIds.map((endpointId) => { + const endpoint = this.#clusterTree.get(endpointId); + if (!endpoint) { + return { x: 0, y: 0 }; + } + const dx = endpoint.circle.x - container.circle.x; + const dy = endpoint.circle.y - container.circle.y; + const dist = Math.hypot(dx, dy) || 1; + return { + x: (dx / dist) * container.circle.radius, + y: (dy / dist) * container.circle.radius, + }; + }); + + layout.updateAnchorPositions(positions); + } + } + + /** + * Whether a reused cluster layout must be rebuilt: the child set changed, or a + * freshly-sized child now overlaps a neighbour at its frozen position (see + * {@link layoutNeedsRebuild}). The layout nodes carry the current positions; + * `child.circle.radius` is the radius just recomputed for this commit. + */ + #clusterLayoutStale(layout: LayoutSimulation, parent: ClusterNode): boolean { + return layoutNeedsRebuild( + layout.nodes.map((node) => ({ + id: node.id, + x: node.x ?? 0, + y: node.y ?? 0, + })), + parent.children.map((child) => ({ + id: child.id, + radius: child.circle.radius, + })), + ); + } + + #ensureChildrenLayout(parent: ClusterNode): void { + const key = parent.id; + let layout = this.#forceLayouts.get(key); + + // Keep the persisted top-level positions current from the live layout, so a + // recreation/rebuild below re-seeds each existing cluster where it is now + // (and anchors the optimiser to it). Root only: it's the hierarchy overview + // whose stability the user notices. + if (parent.kind === "root" && layout) { + this.#snapshotTopLevelPositions(layout); + } + + // Invalidate if the child set changed OR a freshly-sized child now overlaps + // a neighbour at its frozen position (a cluster that grew from 70 to 2000 + // entities, say). Harmless growth with slack around it does NOT rebuild, so + // ordinary ingest doesn't re-churn the layout; only an actual overlap does, + // and recreating re-runs WebCola's hard non-overlap with the new radii, + // warm-seeded from the persisted positions for continuity. + if (layout && this.#clusterLayoutStale(layout, parent)) { + this.#forceLayouts.delete(key); + this.#anchorEndpoints.delete(key); + layout = undefined; + } + + if (!layout) { + // Child radii are assigned before this runs (family children by the + // bottom-up circle-packing pass (#assignRadii), subdivided type-set + // children by #layoutChildrenInParent), so a freshly-arrived child can no + // longer carry a zero/stale radius here. Top-level children re-seed from + // their persisted position when they have one (so a recreated layout, or a + // family node rebuilt as a fresh object, keeps its place); only genuinely + // new clusters fall back to the cluster-tree seed. + const nodes: ForceNode[] = parent.children.map((child) => { + const persisted = + parent.kind === "root" + ? this.#topLevelPositions.get(child.id) + : undefined; + return { + id: child.id, + x: persisted ? persisted.x : child.circle.x - parent.circle.x, + y: persisted ? persisted.y : child.circle.y - parent.circle.y, + radius: child.circle.radius, + }; + }); + + const edges = this.#buildClusterEdges(parent.children); + + // Capture inter-sibling edges as node-index pairs for the D1 untangle, + // before createClusterLayout, since d3 forceLink mutates edge.source/ + // target from ids into node objects in place. + const indexOf = new Map(); + for (let idx = 0; idx < parent.children.length; idx++) { + indexOf.set(parent.children[idx]!.id, idx); + } + const edgeIndices: [number, number][] = []; + for (const edge of edges) { + const a = indexOf.get(edge.source as string); + const b = indexOf.get(edge.target as string); + if (a !== undefined && b !== undefined) { + edgeIndices.push([a, b]); + } + } + + // Root has no confinement; top-level clusters are free-floating. + const confinement = + parent.kind === "root" ? undefined : parent.circle.radius; + layout = createClusterLayout(nodes, edges, confinement); + // Warm up so the first frame isn't the raw ring seed. + layout.tick(20); + const childById = new Map(parent.children.map((ch) => [ch.id, ch])); + for (const node of layout.nodes) { + const child = childById.get(node.id as ClusterId); + if (child) { + child.circle.x = parent.circle.x + (node.x ?? 0); + child.circle.y = parent.circle.y + (node.y ?? 0); + } + } + this.#forceLayouts.set(key, layout); + this.#layoutKind.set(key, "clusters"); + this.#clusterEdges.set(key, edgeIndices); + this.#untangled.delete(key); + this.#ensureSchedulerRunning(); + + // A small layout (e.g. the root's handful of top-level clusters) can fully + // settle inside the 20ms warm-up above. The scheduler loop then skips it + // (status === settled) and never fires the settle-polish, so run it now. + if (layout.isSettled) { + this.#polishSettledLayout(parent, layout); + } + } + } + + /** Write a children layout's local node positions back to child world circles. */ + #writeChildCircles(cluster: ClusterNode, layout: LayoutSimulation): void { + const childById = new Map(cluster.children.map((ch) => [ch.id, ch])); + for (const node of layout.nodes) { + const child = childById.get(node.id as ClusterId); + if (child) { + child.circle.x = cluster.circle.x + (node.x ?? 0); + child.circle.y = cluster.circle.y + (node.y ?? 0); + } + } + } + + /** + * Record the root layout's current LOCAL node positions into + * {@link #topLevelPositions} (by cluster id) so the next recreation/rebuild + * can re-seed and anchor each existing cluster to where it is now. Cheap (a + * handful of top-level nodes); called on every commit and after the optimiser. + */ + #snapshotTopLevelPositions(layout: LayoutSimulation): void { + for (const node of layout.nodes) { + this.#topLevelPositions.set(node.id as ClusterId, { + x: node.x ?? 0, + y: node.y ?? 0, + }); + } + } + + /** + * Authoritative world-position recomposition over the opened subtree. Replaces + * the old per-tick, clusterMoved-gated propagation: world circles are now + * recomposed before every positional read (tick and commit), so nested leaves + * never lag a moved ancestor and a commit issued while the macro is settled + * can't emit stale depth >= 2 positions. See {@link syncWorldPositions}. + */ + #syncWorldPositions(): void { + syncWorldPositions( + this.#clusterTree.root, + (id) => this.#forceLayouts.get(id), + (id) => this.#layoutKind.get(id) === "clusters", + ); + } + + /** + * The once-per-layout settle polish: the principled optimiser for the root, + * the untangle for sub-clusters. Idempotent via {@link #untangled}, and called + * from both the tick loop (settle transition) and {@link #ensureChildrenLayout} + * (a small layout that settled inside its warm-up would otherwise be skipped by + * the tick loop forever, so its polish would never run). + */ + #polishSettledLayout(cluster: ClusterNode, layout: LayoutSimulation): void { + if (this.#untangled.has(cluster.id)) { + return; + } + if (cluster.kind === "root") { + this.#optimizeTopLevelLayout(cluster, layout); + } else { + this.#untangleClusterLayout(cluster, layout); + } + this.#untangled.add(cluster.id); + } + + /** + * The principled top-level pass (root only): replace the force-settled + * positions with the layout that minimises the drawn geometry (crossings + + * detours + edge length + non-overlap + neighbour spread), all on rim-to-rim + * (port) segments (see {@link optimizeTopLevel}). The top level is unconfined, + * size-disparate, and the overview everything else inherits, so it's solved + * directly rather than via stress + a crossings polish that fight each other. + * Runs once on settle, like the untangle. + */ + #optimizeTopLevelLayout( + cluster: ClusterNode, + layout: LayoutSimulation, + ): void { + const edges = this.#clusterEdges.get(cluster.id); + const nodeList = layout.nodes; + if ( + !edges || + edges.length === 0 || + nodeList.length < 3 || + nodeList.length > TOP_LEVEL_MAX_NODES + ) { + // Too small/large to optimise, but still record positions so a later + // recreation re-seeds from the current layout, not a stale snapshot. + this.#snapshotTopLevelPositions(layout); + return; + } + + const nodes: LayoutNode[] = nodeList.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + })); + + // Anchor each cluster that existed in the previous layout to its persisted + // position (a local refine that keeps the mental map); leave genuinely-new + // clusters unanchored so they're placed freely. The anchor strength falls + // off with distance from the viewport centre (scaled by zoom), so what the + // user is looking at stays put while off-screen bubbles can reflow. See + // {@link optimizeTopLevel} and {@link viewportAnchorWeight}. + const viewport = this.#viewport; + const anchors: (Anchor | null)[] = nodeList.map((node) => { + const previous = this.#topLevelPositions.get(node.id as ClusterId); + if (!previous) { + return null; + } + const weight = viewport + ? viewportAnchorWeight( + cluster.circle.x + previous.x, + cluster.circle.y + previous.y, + viewport, + ) + : 1; + return { x: previous.x, y: previous.y, weight }; + }); + + optimizeTopLevel(nodes, edges, hashId(cluster.id), { anchors }); + + for (let idx = 0; idx < nodeList.length; idx++) { + nodeList[idx]!.x = nodes[idx]!.x; + nodeList[idx]!.y = nodes[idx]!.y; + } + this.#writeChildCircles(cluster, layout); + // The optimised positions are now the layout the user sees; anchor the next + // incremental refine to them. + this.#snapshotTopLevelPositions(layout); + } + + /** + * D1: polish a settled cluster layout once, minimising edge crossings and + * edges-through-bubbles while staying near the force-settled seed. Only for + * small layouts (<= {@link UNTANGLE_MAX_NODES}); larger ones keep the force + * result (a stress-majorization tier could slot in here later). + */ + #untangleClusterLayout(cluster: ClusterNode, layout: LayoutSimulation): void { + const edges = this.#clusterEdges.get(cluster.id); + const nodeList = layout.nodes; + if (!edges || nodeList.length < 3 || nodeList.length > UNTANGLE_MAX_NODES) { + return; + } + + const nodes: UntangleNode[] = nodeList.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + radius: node.radius, + })); + + untangleLayout(nodes, { + edges, + confinementRadius: + cluster.kind === "root" + ? Number.POSITIVE_INFINITY + : cluster.circle.radius, + seed: hashId(cluster.id), + }); + + for (let idx = 0; idx < nodeList.length; idx++) { + nodeList[idx]!.x = nodes[idx]!.x; + nodeList[idx]!.y = nodes[idx]!.y; + } + this.#writeChildCircles(cluster, layout); + } + + #ensureEntityLayout(cluster: ClusterNode): void { + const key = cluster.id; + const existing = this.#forceLayouts.get(key); + if (existing) { + if (existing.nodes.length === cluster.count) { + return; + } + this.#forceLayouts.delete(key); + this.#entityPortTargets.delete(key); + this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId: key }); + } + + const entityIdxs = this.#collectEntityIdxsForCluster(cluster); + const parentR = cluster.circle.radius; + const entityRadius = parentR * ENTITY_RADIUS_FRACTION; + + // Deterministic phyllotaxis (sunflower) seeding: even, stable disk fill so + // re-opening a cluster lands entities in the same place each time. + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const fillRadius = parentR * 0.85; + const nodes: ForceNode[] = []; + for (let idx = 0; idx < entityIdxs.length; idx++) { + const dist = fillRadius * Math.sqrt((idx + 0.5) / entityIdxs.length); + const angle = idx * goldenAngle; + nodes.push({ + id: String(entityIdxs[idx]), + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + radius: entityRadius, + }); + } + + const edges = this.#buildEntityEdges(entityIdxs, nodes); + // Live port-attraction targets (one (x,y) per entity, NaN = no external + // connection); #buildEntityLayers fills them and the layout's force reads + // them each tick, so dots track their ports instead of fanning to a stale + // baked exit. + const portTargets = new Float32Array(entityIdxs.length * 2).fill( + Number.NaN, + ); + this.#entityPortTargets.set(key, portTargets); + const layout = createEntityLayout( + nodes, + edges, + cluster.circle.radius, + portTargets, + ); + this.#forceLayouts.set(key, layout); + this.#layoutKind.set(key, "entities"); + // Per-node colours are written once here (leaf-buffer creation) and again only on a + // highlight change (via #applyHighlight) -- never per commit, which would re-write + + // re-upload every open leaf's colours on each LOD threshold crossing while zooming. + this.#writeLeafColors(cluster, layout); + this.#ensureSchedulerRunning(); + + // Shared-buffer reference so the main thread reads entity positions directly. + // Radius, color, and the leaf's world origin travel in the StructureFrame. + this.#onLayoutMessage?.({ + type: "LAYOUT_CREATED", + clusterId: cluster.id, + buffer: layout.buffer, + nodeIds: layout.nodeIds, + }); + } + + #collectEntityIdxsForCluster(cluster: ClusterNode): EntityIdx[] { + if (cluster.membership.source === "direct") { + const view = cluster.membership.members.subarray(); + const result: EntityIdx[] = []; + for (const idx of view) { + result.push(idx); + } + return result; + } + + const result: EntityIdx[] = []; + for (const key of cluster.membership.keys) { + const group = this.#typeSets.get(key); + if (group) { + for (const idx of group.entityIdxs) { + result.push(idx); + } + } + } + + // A family/rollup carries no keys of its own; its entities live in its + // children (e.g. Customer/Supplier inside the Company family). Recurse so + // those entities are attributed to it; otherwise links into the family + // (Delivery -> Customer) are dropped from the cluster-edge set, the optimiser + // never gets a Company <-> Delivery edge, and the family floats far from its + // true neighbour while the renderer still draws the (long) edge. Only when + // the node has no own entities: a subdivided type-set already covers all of + // its entities via its keys, so recursing into its partition would + // double-count. + if (result.length === 0) { + for (const child of cluster.children) { + for (const idx of this.#collectEntityIdxsForCluster(child)) { + result.push(idx); + } + } + } + return result; + } + + #buildClusterEdges(children: readonly ClusterNode[]): ForceEdge[] { + // Build entityIdx -> childId lookup once. + const entityToChild = new Map(); + const childEntityIdxs = new Map(); + for (const child of children) { + const entityIdxs = this.#collectEntityIdxsForCluster(child); + childEntityIdxs.set(child.id, entityIdxs); + for (const entityIdx of entityIdxs) { + entityToChild.set(entityIdx, child.id); + } + } + + // Count links between sibling clusters via O(1) lookups. This same count is + // also what the aggregator draws as the highway between the pair, so layout + // attraction and highway width derive from one quantity. + const edgeCounts = new Map(); + for (const child of children) { + const entityIdxs = childEntityIdxs.get(child.id)!; + for (const entityIdx of entityIdxs) { + const links = this.#links.linksForEntity(entityIdx); + for (const link of links) { + const otherChildId = entityToChild.get(link.otherIdx); + if (otherChildId === undefined || otherChildId === child.id) { + continue; + } + const pairKey = + child.id < otherChildId + ? `${child.id}|${otherChildId}` + : `${otherChildId}|${child.id}`; + edgeCounts.set(pairKey, (edgeCounts.get(pairKey) ?? 0) + 1); + } + } + } + + const edges: ForceEdge[] = []; + for (const [pairKey, weight] of edgeCounts) { + const [sourceId, targetId] = pairKey.split("|") as [string, string]; + edges.push({ source: sourceId, target: targetId, weight }); + } + + return edges; + } + + #buildEntityEdges(entityIdxs: EntityIdx[], nodes: ForceNode[]): ForceEdge[] { + const memberSet = new Set(entityIdxs); + const idxToNodeId = new Map(); + for (let idx = 0; idx < entityIdxs.length; idx++) { + idxToNodeId.set(entityIdxs[idx]!, nodes[idx]!.id); + } + + const edges: ForceEdge[] = []; + const seen = new Set(); + + for (const entityIdx of entityIdxs) { + const links = this.#links.linksForEntity(entityIdx); + for (const link of links) { + if (!memberSet.has(link.otherIdx)) { + continue; + } + const sourceId = idxToNodeId.get(entityIdx)!; + const targetId = idxToNodeId.get(link.otherIdx)!; + const pairKey = + sourceId < targetId + ? `${sourceId}|${targetId}` + : `${targetId}|${sourceId}`; + if (seen.has(pairKey)) { + continue; + } + seen.add(pairKey); + edges.push({ source: sourceId, target: targetId, weight: 1 }); + } + } + + return edges; + } + + #trySubdivide(node: ClusterNode): boolean { + const subdivided = this.#clusterTree.ensureSubclusters( + node, + this.#typeSets, + this.#links, + this.config, + ); + + if (!subdivided) { + return false; + } + + // If children are entity-buckets (deterministic partition), + // queue an embedding request to upgrade them. + const hasEntityBuckets = node.children.some( + (child) => child.kind === "entity-bucket", + ); + + if ( + hasEntityBuckets && + this.#clusterTree.needsEmbeddingSubdivision(node, this.config) + ) { + const entityIds = this.#collectEntityIdsForCluster(node); + const targetSize = Math.floor( + this.config.entityRevealMax * this.config.embeddingTargetLeafFillRatio, + ); + const clusterCount = Math.max( + 2, + Math.min( + this.config.embeddingMaxK, + Math.ceil(entityIds.length / targetSize), + ), + ); + + this.#clusterTree.markSubdivisionRequested(node.id); + this.#pendingEmbeddingRequests.push({ + type: "EMBEDDING_CLUSTERING_NEEDED", + clusterId: node.id, + entityIds, + clusterCount, + }); + } + + // Name the grouping fallback (link-signature `community` + `entity-bucket` chunks) by the + // same distinctive-feature machinery, so groups carry a meaningful name BEFORE (or without) + // embeddings. Type-set children are excluded -- the type labeler already names those; random + // chunks self-filter (no feature is distinctive of a random slice, so they keep `Group n`). + // If embeddings later arrive they REPLACE these children and re-name via applyEmbeddingResult. + const fallbackGroups: ClusterMembers[] = []; + for (const child of node.children) { + if ( + (child.kind === "community" || child.kind === "entity-bucket") && + child.membership.source === "direct" + ) { + fallbackGroups.push({ + childId: child.id, + memberIdxs: child.membership.members.subarray().view, + }); + } + } + this.#scheduleDistinctiveFeatureNaming(fallbackGroups); + + return true; + } + + #collectEntityIdsForCluster(node: ClusterNode): string[] { + if (node.membership.source === "groups") { + const entityIds: string[] = []; + for (const key of node.membership.keys) { + const group = this.#typeSets.get(key); + if (group) { + for (const idx of group.entityIdxs) { + entityIds.push(this.#entities.getEntityId(idx)); + } + } + } + return entityIds; + } + + const members = node.membership.members.subarray(); + const entityIds = Array.from({ length: members.length }); + for (let idx = 0; idx < members.length; idx++) { + entityIds[idx] = this.#entities.getEntityId(members.get(idx)); + } + + return entityIds; + } + + /** Drain pending embedding requests. Called by entry.ts after frame dispatch. */ + drainEmbeddingRequests(): EmbeddingClusteringNeededMessage[] { + if (this.#pendingEmbeddingRequests.length === 0) { + return []; + } + const requests = [...this.#pendingEmbeddingRequests]; + this.#pendingEmbeddingRequests.length = 0; + return requests; + } + + /** Apply server-side embedding clustering results. */ + applyEmbeddingResult( + clusterId: ClusterId, + clusters: readonly { + readonly clusterId: number; + readonly entityIds: readonly string[]; + }[], + ): void { + const assignments = clusters.map((embeddingCluster) => { + const memberIdxs = new Int32Array(embeddingCluster.entityIds.length); + for (let idx = 0; idx < embeddingCluster.entityIds.length; idx++) { + const entityIdx = this.#entities.tryGet( + embeddingCluster.entityIds[idx]! as EntityId, + ); + if (entityIdx !== undefined) { + memberIdxs[idx] = entityIdx; + } + } + return { + childId: ClusterId( + `${clusterId}:embedding:${embeddingCluster.clusterId}`, + ), + count: embeddingCluster.entityIds.length, + memberIdxs, + }; + }); + + this.#clusterTree.applyEmbeddingResult(clusterId, assignments); + + // The children render immediately with their "Similar group n" placeholder (set in + // ClusterTree.applyEmbeddingResult); the relabel lands later off the job scheduler. + this.#scheduleDistinctiveFeatureNaming(assignments); + } + + /** + * Schedule distinctive-feature naming over a freshly-created set of SIBLING child clusters -- + * embedding (kmeans) groups OR the non-embedding grouping fallback (link-signature `community` + * buckets + `entity-bucket` chunks), which render with a `Group n`/`Similar group n` + * placeholder. Names from a UNIFIED feature space -- exact `property = value`, numeric/date + * ranges, and link/target-type ("what they link to") -- so groups distinguished by magnitude + * or by their edges get named, not just those with a distinctive categorical value. Deferred + * onto the job scheduler because the scan is O(members x features): the placeholder commit + * paints first, then the relabel + recommit lands once it completes. Both paths share this so + * they name identically; it deliberately does NOT touch the type-set (`distinctiveLabel`) or + * community (`labelAllCommunities`) labelers -- the namer only sets text where it finds a + * confident, distinctive signature, leaving every other group's label intact. + */ + #scheduleDistinctiveFeatureNaming(groups: readonly ClusterMembers[]): void { + if (groups.length === 0) { + return; + } + this.#scheduleJob(() => { + const labels = nameClustersByDistinctiveFeatures( + groups, + createClusterFeatureSource({ + properties: this.#properties, + links: this.#links, + entities: this.#entities, + typeSets: this.#typeSets, + types: this.#types, + }), + ); + if (labels.size === 0) { + return; + } + for (const [childId, label] of labels) { + this.#clusterTree.setLabelText(childId, label); + } + this.commitStructure(); + }); + } + + #resolvePendingLinks( + entityId: IngestEntity["entityId"], + entityIdx: EntityIdx, + ): void { + const pending = this.#links.takePending(entityId); + if (!pending) { + return; + } + + for (const linkIdx of pending) { + this.#links.resolveEndpoint(linkIdx, "left", entityIdx); + this.#links.resolveEndpoint(linkIdx, "right", entityIdx); + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts new file mode 100644 index 00000000000..bdcc3984e00 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts @@ -0,0 +1,93 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { + layoutNeedsRebuild, + OVERLAP_REBUILD_TOLERANCE_FRAC, +} from "./layout-reuse"; + +/** Two bubbles 100 apart on the x-axis; radii supplied per case. */ +const pairAt = (radiusA: number, radiusB: number) => + ({ + previous: [ + { id: "a", x: 0, y: 0 }, + { id: "b", x: 100, y: 0 }, + ], + current: [ + { id: "a", radius: radiusA }, + { id: "b", radius: radiusB }, + ], + }) as const; + +describe("layoutNeedsRebuild", () => { + it("reuses a layout whose children still fit", () => { + const { previous, current } = pairAt(40, 40); // gap of 20 + expect(layoutNeedsRebuild(previous, current)).toBe(false); + }); + + it("rebuilds when the child count changes", () => { + expect( + layoutNeedsRebuild( + [{ id: "a", x: 0, y: 0 }], + [ + { id: "a", radius: 10 }, + { id: "b", radius: 10 }, + ], + ), + ).toBe(true); + }); + + it("rebuilds when a child id is swapped (same count)", () => { + expect( + layoutNeedsRebuild([{ id: "a", x: 0, y: 0 }], [{ id: "b", radius: 10 }]), + ).toBe(true); + }); + + it("reuses a big growth that still has slack (no churn)", () => { + // a: 40 -> 55 (nearly +40%). Edge-to-edge gap shrinks 20 -> 5 but they do + // NOT overlap, so the layout must be kept — this is the anti-churn case. + const { previous, current } = pairAt(55, 40); + expect(layoutNeedsRebuild(previous, current)).toBe(false); + }); + + it("rebuilds on the reported bug: growth that overlaps a neighbour", () => { + // a balloons to 224 (the 70 -> 2000 case); it now buries b at distance 100. + const { previous, current } = pairAt(224, 12); + expect(layoutNeedsRebuild(previous, current)).toBe(true); + }); + + it("does NOT rebuild on a shrink (it only frees space)", () => { + const { previous, current } = pairAt(10, 10); // far smaller than the gap + expect(layoutNeedsRebuild(previous, current)).toBe(false); + }); + + it("tolerates a penetration within the dead-band, rebuilds just past it", () => { + // minDist = rA + rB; penetration = minDist - 100. Tolerance is a fraction of + // the smaller radius. With rB = 50, tolerance = 0.05 * 50 = 2.5. + const tol = OVERLAP_REBUILD_TOLERANCE_FRAC * 50; + expect(tol).toBeCloseTo(2.5); + // penetration = (52 + 50) - 100 = 2 (< 2.5) -> reuse. + const within = pairAt(52, 50); + expect(layoutNeedsRebuild(within.previous, within.current)).toBe(false); + // penetration = (53 + 50) - 100 = 3 (> 2.5) -> rebuild. + const past = pairAt(53, 50); + expect(layoutNeedsRebuild(past.previous, past.current)).toBe(true); + }); + + it("rebuilds when only ONE of many children grows into a neighbour", () => { + expect( + layoutNeedsRebuild( + [ + { id: "a", x: 0, y: 0 }, + { id: "b", x: 100, y: 0 }, + { id: "c", x: 0, y: 100 }, + ], + [ + { id: "a", radius: 20 }, + { id: "b", radius: 90 }, // reaches back across the 100 gap into a + { id: "c", radius: 20 }, + ], + ), + ).toBe(true); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts new file mode 100644 index 00000000000..aba772cc571 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts @@ -0,0 +1,84 @@ +/** + * When a settled cluster layout may be REUSED versus rebuilt from scratch. + * + * A layout is keyed by its parent and reused across commits for stability (so + * the arrangement doesn't churn on every ingest). But a reused layout keeps the + * positions it was solved with, while child radii are recomputed every commit + * (radius ~ sqrt(entity count)). So a child that grows can end up OVERLAPPING a + * neighbour at its frozen position — and, since the child COUNT is unchanged, a + * count-only reuse guard never notices and the overlap is drawn forever. + * + * The trigger is the overlap ITSELF, not a proxy like "radius grew > X%": + * - growth with slack around it (no overlap) does NOT rebuild → no churn; + * - growth INTO a neighbour rebuilds → the overlap is re-solved; + * - a shrink never rebuilds (it only frees space). + * A percentage-of-radius threshold gets both wrong: it rebuilds harmless growth + * (churn) yet misses growth that overlaps in a tightly-packed spot. + */ + +/** + * How far one bubble may penetrate another (as a fraction of the smaller + * radius) before a rebuild is forced. A small dead-band: it stops a freshly + * solved layout — which leaves a little padding between bubbles — from + * rebuilding the instant a child nibbles into that padding, while still firing + * on any real, visible overlap. + */ +export const OVERLAP_REBUILD_TOLERANCE_FRAC = 0.05; + +interface PlacedNode { + readonly id: string; + readonly x: number; + readonly y: number; +} + +interface SizedNode { + readonly id: string; + readonly radius: number; +} + +/** + * Whether a reused layout (`previous`, the live layout nodes with their current + * positions) must be rebuilt to fit the `current` children (same ids, but + * freshly-sized radii): the child set changed, or some child now overlaps a + * neighbour by more than `toleranceFrac` of the smaller radius. Pure and + * side-effect free so it can be unit-tested directly. + */ +export function layoutNeedsRebuild( + previous: readonly PlacedNode[], + current: readonly SizedNode[], + toleranceFrac: number = OVERLAP_REBUILD_TOLERANCE_FRAC, +): boolean { + // A child added or removed: the set changed, rebuild. + if (previous.length !== current.length) { + return true; + } + const positionById = new Map(); + for (const node of previous) { + positionById.set(node.id, node); + } + // Join the new radii onto the current positions; a missing id means the set + // changed even though the count matched. + const placed: { x: number; y: number; radius: number }[] = []; + for (const child of current) { + const position = positionById.get(child.id); + if (position === undefined) { + return true; + } + placed.push({ x: position.x, y: position.y, radius: child.radius }); + } + // Any pair overlapping (beyond the dead-band) at the frozen positions with the + // new radii means the reused layout is no longer feasible. + for (let idxA = 0; idxA < placed.length; idxA++) { + const nodeA = placed[idxA]!; + for (let idxB = idxA + 1; idxB < placed.length; idxB++) { + const nodeB = placed[idxB]!; + const distance = Math.hypot(nodeB.x - nodeA.x, nodeB.y - nodeA.y); + const penetration = nodeA.radius + nodeB.radius - distance; + const tolerance = toleranceFrac * Math.min(nodeA.radius, nodeB.radius); + if (penetration > tolerance) { + return true; + } + } + } + return false; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.test.ts new file mode 100644 index 00000000000..0e5ead9b540 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.test.ts @@ -0,0 +1,79 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { VIEWPORT_ANCHOR_FLOOR, viewportAnchorWeight } from "./viewport-anchor"; + +import type { ViewportState } from "../hierarchy/lod"; + +/** A 1000x1000 viewport centred on the origin at the given zoom. */ +const viewportAt = (zoom: number): ViewportState => ({ + zoom, + centerX: 0, + centerY: 0, + width: 1000, + height: 1000, +}); + +describe("viewportAnchorWeight", () => { + it("pins the viewport centre (~1) and frees the far field (~floor)", () => { + const viewport = viewportAt(0); + expect(viewportAnchorWeight(0, 0, viewport)).toBeCloseTo(1, 5); + expect(viewportAnchorWeight(100_000, 0, viewport)).toBeCloseTo( + VIEWPORT_ANCHOR_FLOOR, + 5, + ); + }); + + it("decreases monotonically with distance from the centre", () => { + const viewport = viewportAt(2); + const w0 = viewportAnchorWeight(0, 0, viewport); + const w1 = viewportAnchorWeight(50, 0, viewport); + const w2 = viewportAnchorWeight(150, 0, viewport); + const w3 = viewportAnchorWeight(400, 0, viewport); + expect(w0).toBeGreaterThan(w1); + expect(w1).toBeGreaterThan(w2); + expect(w2).toBeGreaterThan(w3); + }); + + it("strengthens the centre pin as you zoom in (the fix)", () => { + // Same bubble, dead centre, viewed at increasing zoom: it must be pinned + // harder so its SCREEN movement doesn't balloon. + const atZoom0 = viewportAnchorWeight(0, 0, viewportAt(0)); + const atZoom4 = viewportAnchorWeight(0, 0, viewportAt(4)); + expect(atZoom4).toBeGreaterThan(atZoom0 * 8); + }); + + it("holds the centre's SCREEN movement ~constant across zoom-in", () => { + // World wobble ~ 1/weight, screen wobble ~ scale/weight. With the zoom + // amplification this proxy stays ~flat; WITHOUT it (weight capped at 1) it + // would grow with scale (1, 4, 16, ...). Assert it barely varies. + const proxies = [0, 1, 2, 3, 4, 5].map((zoom) => { + const scale = 2 ** zoom; + return scale / viewportAnchorWeight(0, 0, viewportAt(zoom)); + }); + const max = Math.max(...proxies); + const min = Math.min(...proxies); + expect(max / min).toBeLessThan(1.1); + }); + + it("does NOT amplify off-screen bubbles when zoomed in", () => { + // A bubble far outside the view stays at the floor regardless of zoom, so it + // remains free to reflow where the user can't see it. + const far = 100_000; + expect(viewportAnchorWeight(far, 0, viewportAt(0))).toBeCloseTo( + VIEWPORT_ANCHOR_FLOOR, + 5, + ); + expect(viewportAnchorWeight(far, 0, viewportAt(5))).toBeCloseTo( + VIEWPORT_ANCHOR_FLOOR, + 5, + ); + }); + + it("keeps the baseline when zoomed OUT (no de-pinning below 1x)", () => { + // Zooming out must not loosen the anchor below its zoom-0 baseline. + const centreZoom0 = viewportAnchorWeight(0, 0, viewportAt(0)); + const centreZoomOut = viewportAnchorWeight(0, 0, viewportAt(-3)); + expect(centreZoomOut).toBeCloseTo(centreZoom0, 5); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts new file mode 100644 index 00000000000..d14d2530098 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts @@ -0,0 +1,50 @@ +/** + * How strongly a top-level bubble resists moving during an incremental refine, + * derived from where it sits on the user's screen. + * + * Two ideas combine: + * - CENTRALITY: pin what the user is looking at, let the rest reflow. The weight + * is ~1 at the viewport centre and decays (Gaussian) to {@link + * VIEWPORT_ANCHOR_FLOOR} once a bubble is roughly off-screen. The falloff + * radius is the viewport's visible half-diagonal in WORLD units, so it scales + * with zoom: zoomed in, only the few central bubbles are held; zoomed out, + * most of the graph is. + * - ZOOM (screen-space stability): the inertia is penalised in WORLD units, but + * the user perceives SCREEN units, and `scale = 2**zoom` is screen px per world + * unit. So a wobble that's negligible in the world is magnified by `scale` when + * zoomed in — a bubble you've zoomed right into visibly drifts even though it + * barely moved. We amplify the on-screen pin by `scale` (clamped to ≥ 1× so + * zooming OUT keeps the baseline) to hold the focused bubble's SCREEN movement + * roughly constant across zoom. The amplification multiplies only the centrality + * term, so off-screen bubbles stay at the floor and remain free to reflow. + */ + +import type { ViewportState } from "../hierarchy/lod"; + +/** Off-screen bubbles keep this much anchor: they reflow but don't teleport while + * the user isn't looking. Also the floor of {@link viewportAnchorWeight}. */ +export const VIEWPORT_ANCHOR_FLOOR = 0.05; + +export function viewportAnchorWeight( + worldX: number, + worldY: number, + viewport: ViewportState, +): number { + const scale = 2 ** viewport.zoom; + const visibleRadius = + Math.hypot(viewport.width, viewport.height) / 2 / Math.max(scale, 1e-6); + if (!(visibleRadius > 0)) { + return 1; + } + const distance = Math.hypot( + worldX - viewport.centerX, + worldY - viewport.centerY, + ); + const falloff = Math.exp(-((distance / visibleRadius) ** 2)); + // Amplify only the on-screen (high-falloff) term by the zoom, never below 1×. + // Off-screen bubbles have falloff ≈ 0, so they stay at the floor at any zoom. + const zoomStrength = Math.max(1, scale); + return ( + VIEWPORT_ANCHOR_FLOOR + (1 - VIEWPORT_ANCHOR_FLOOR) * falloff * zoomStrength + ); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts new file mode 100644 index 00000000000..79433862b61 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts @@ -0,0 +1,61 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { Column } from "./collections/column"; +import { connectedComponents } from "./csr-graph"; + +import type { EntityIdx } from "../ids"; +import type { CsrGraph } from "./csr-graph"; + +/** Build a CsrGraph from an undirected adjacency list keyed by local index. */ +const csrFrom = (adjacency: number[][]): CsrGraph => { + const nodeIds = new Column( + Int32Array, + Math.max(1, adjacency.length), + ); + for (let index = 0; index < adjacency.length; index++) { + nodeIds.push(index as EntityIdx); + } + const offsets = new Int32Array(adjacency.length + 1); + const flat: number[] = []; + for (let index = 0; index < adjacency.length; index++) { + offsets[index] = flat.length; + flat.push(...adjacency[index]!); + } + offsets[adjacency.length] = flat.length; + return { + nodeIds, + offsets, + neighbors: Int32Array.from(flat), + weights: Float32Array.from(flat, () => 1), + }; +}; + +/** Components in a canonical order so the assertion is independent of traversal order. */ +const normalize = (components: number[][]) => + components + .map((component) => [...component].sort((lhs, rhs) => lhs - rhs)) + .sort((lhs, rhs) => lhs[0]! - rhs[0]!); + +describe("connectedComponents", () => { + it("separates disconnected components and isolated nodes", () => { + // 0-1, 2-3, and 4 isolated + const graph = csrFrom([[1], [0], [3], [2], []]); + expect(normalize(connectedComponents(graph))).toEqual([ + [0, 1], + [2, 3], + [4], + ]); + }); + + it("returns a single component when fully connected", () => { + // path 0-1-2-3 + const graph = csrFrom([[1], [0, 2], [1, 3], [2]]); + expect(normalize(connectedComponents(graph))).toEqual([[0, 1, 2, 3]]); + }); + + it("returns one component per node when there are no edges", () => { + const graph = csrFrom([[], [], []]); + expect(normalize(connectedComponents(graph))).toEqual([[0], [1], [2]]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts new file mode 100644 index 00000000000..73e988cc913 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts @@ -0,0 +1,111 @@ +/** + * Compressed sparse row (CSR) graph over a set of entities, plus the operations the + * community sub-clustering needs (build from the link store, connected components). Pure: + * it operates on the data passed in, holding no worker state. + */ +import type { EntityIdx } from "../ids"; +import type { Column } from "./collections/column"; +import type { LinkStore } from "./stores/link-store"; + +export interface CsrGraph { + readonly nodeIds: Column; + /** Maps local index -> offset into neighbors/weights. Length = nodeIds.length + 1. */ + readonly offsets: Int32Array; + readonly neighbors: Int32Array; + readonly weights: Float32Array; +} + +/** + * Build a compressed sparse row graph from the link store, restricted to the given entity + * set (links with an endpoint outside the set, or a self-loop, are dropped). + */ +export function buildInducedCsr( + entityIdxs: Column, + links: LinkStore, +): CsrGraph { + const localIndex = new Map(); + for (let idx = 0; idx < entityIdxs.length; idx++) { + localIndex.set(entityIdxs.get(idx), idx); + } + + const adjacency: { neighbor: number; weight: number }[][] = [ + ...entityIdxs, + ].map(() => []); + + for (let linkIdx = 0; linkIdx < links.count; linkIdx++) { + const left = links.getLeft(linkIdx); + const right = links.getRight(linkIdx); + if (left === -1 || right === -1) { + continue; + } + + const leftLocal = localIndex.get(left); + const rightLocal = localIndex.get(right); + if (leftLocal === undefined || rightLocal === undefined) { + continue; + } + if (leftLocal === rightLocal) { + continue; + } + + adjacency[leftLocal]!.push({ neighbor: rightLocal, weight: 1 }); + adjacency[rightLocal]!.push({ neighbor: leftLocal, weight: 1 }); + } + + const totalEdges = adjacency.reduce((sum, adj) => sum + adj.length, 0); + const offsets = new Int32Array(entityIdxs.length + 1); + const neighbors = new Int32Array(totalEdges); + const weights = new Float32Array(totalEdges); + + let offset = 0; + for (let idx = 0; idx < adjacency.length; idx++) { + offsets[idx] = offset; + for (const edge of adjacency[idx]!) { + neighbors[offset] = edge.neighbor; + weights[offset] = edge.weight; + offset++; + } + } + offsets[entityIdxs.length] = offset; + + return { nodeIds: entityIdxs, offsets, neighbors, weights }; +} + +/** Connected components of the graph, each a list of local node indices. */ +export function connectedComponents(graph: CsrGraph): number[][] { + const nodeCount = graph.nodeIds.length; + const visited = new Uint8Array(nodeCount); + const components: number[][] = []; + const queue: number[] = []; + + for (let start = 0; start < nodeCount; start++) { + if (visited[start]) { + continue; + } + + const component: number[] = []; + queue.push(start); + visited[start] = 1; + + while (queue.length > 0) { + const node = queue.pop()!; + component.push(node); + + for ( + let edge = graph.offsets[node]!; + edge < graph.offsets[node + 1]!; + edge++ + ) { + const neighbor = graph.neighbors[edge]!; + if (!visited[neighbor]) { + visited[neighbor] = 1; + queue.push(neighbor); + } + } + } + + components.push(component); + } + + return components; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts new file mode 100644 index 00000000000..71acb83a239 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts @@ -0,0 +1,56 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { + ENTITY_ID_BYTES, + decodeEntityId, + encodeEntityId, +} from "./entity-id-codec"; + +import type { DraftId, EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webIdA = "11111111-1111-4111-8111-111111111111" as WebId; +const uuidA = "22222222-2222-4222-8222-222222222222" as EntityUuid; +const webIdB = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" as WebId; +const uuidB = "12345678-90ab-4cde-8f01-234567890abc" as EntityUuid; +const draftId = "33333333-3333-4333-8333-333333333333" as DraftId; + +const recordBytes = (records: number) => + new Uint8Array(ENTITY_ID_BYTES * records); + +describe("entity-id-codec", () => { + it("round-trips a non-draft EntityId at an arbitrary index", () => { + const bytes = recordBytes(4); + const id = entityIdFromComponents(webIdA, uuidA); + encodeEntityId(bytes, 2, id); + expect(decodeEntityId(bytes, 2)).toBe(id); + }); + + it("preserves the draftId through a round-trip", () => { + const bytes = recordBytes(4); + const id = entityIdFromComponents(webIdA, uuidA, draftId); + encodeEntityId(bytes, 0, id); + expect(decodeEntityId(bytes, 0)).toBe(id); + }); + + it("keeps records independent by index", () => { + const bytes = recordBytes(8); + const first = entityIdFromComponents(webIdA, uuidA); + const second = entityIdFromComponents(webIdB, uuidB, draftId); + encodeEntityId(bytes, 0, first); + encodeEntityId(bytes, 7, second); + expect(decodeEntityId(bytes, 0)).toBe(first); + expect(decodeEntityId(bytes, 7)).toBe(second); + }); + + it("zeroes the draftId slot when overwriting a draft with a non-draft id", () => { + const bytes = recordBytes(2); + encodeEntityId(bytes, 1, entityIdFromComponents(webIdA, uuidA, draftId)); + const plain = entityIdFromComponents(webIdB, uuidB); + encodeEntityId(bytes, 1, plain); + // if the stale draftId leaked, the decoded id would still carry it + expect(decodeEntityId(bytes, 1)).toBe(plain); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts new file mode 100644 index 00000000000..0da663eaae5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts @@ -0,0 +1,70 @@ +/** + * Thread-agnostic codec for the EntityIdx -> EntityId join map's byte layout. The worker + * (buffers/entity-id-buffer.ts) writes it; the main thread reads it on demand. One module + * owns the packing so both sides agree on the layout: per record, + * `webId (16) | entityUuid (16) | draftId (16)`, each UUID packed to 16 bytes, with the + * draftId slot all-zero when the entity is not a draft. + */ +import { + type DraftId, + type EntityId, + type EntityUuid, + type WebId, + entityIdFromComponents, + splitEntityId, +} from "@blockprotocol/type-system"; + +/** Bytes per UUID (128 bits). */ +const UUID_BYTES = 16; +/** Bytes per record: webId + entityUuid + draftId. */ +export const ENTITY_ID_BYTES = UUID_BYTES * 3; +/** `version: int32`; also the byte offset where the records begin, so a reader builds a + * records-region view with `new Uint8Array(raw, ID_HEADER_BYTES)`. */ +export const ID_HEADER_BYTES = 4; +/** The draftId slot when the entity is not a draft. */ +const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; + +/** Pack a hyphenated UUID string into 16 bytes at `offset` (in place, zero-alloc). */ +function writeUuid(target: Uint8Array, offset: number, uuid: string): void { + const hex = uuid.replace(/-/g, ""); + for (let i = 0; i < UUID_BYTES; i++) { + // Writing into the caller's buffer is the point; a fresh array per UUID would be a + // perf nightmare. + // eslint-disable-next-line no-param-reassign + target[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } +} + +/** Reconstruct a hyphenated UUID string from 16 bytes at `offset`. */ +function readUuid(source: Uint8Array, offset: number): string { + let hex = ""; + for (let i = 0; i < UUID_BYTES; i++) { + hex += source[offset + i]!.toString(16).padStart(2, "0"); + } + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** Write the EntityId for `entityIdx` into a records-region view: its webId, entityUuid, + * and draftId (or a zeroed draftId slot when the entity is not a draft). */ +export function encodeEntityId( + bytes: Uint8Array, + entityIdx: number, + entityId: EntityId, +): void { + const [webId, entityUuid, draftId] = splitEntityId(entityId); + const base = entityIdx * ENTITY_ID_BYTES; + writeUuid(bytes, base, webId); + writeUuid(bytes, base + UUID_BYTES, entityUuid); + writeUuid(bytes, base + UUID_BYTES * 2, draftId ?? ZERO_UUID); +} + +/** Reconstruct the EntityId for `entityIdx` from a records-region view. */ +export function decodeEntityId(bytes: Uint8Array, entityIdx: number): EntityId { + const base = entityIdx * ENTITY_ID_BYTES; + const webId = readUuid(bytes, base) as WebId; + const entityUuid = readUuid(bytes, base + UUID_BYTES) as EntityUuid; + const draftIdRaw = readUuid(bytes, base + UUID_BYTES * 2); + const draftId = + draftIdRaw === ZERO_UUID ? undefined : (draftIdRaw as DraftId); + return entityIdFromComponents(webId, entityUuid, draftId); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts new file mode 100644 index 00000000000..7e1cac20c78 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts @@ -0,0 +1,171 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { + colorForType, + edgeColorForType, + primaryTypeOfSet, + radiusForDegree, +} from "./entity-style"; +import { TypeRegistry } from "./stores/type-registry"; + +import type { TypeIdx } from "../ids"; +import type { TypeSchemaEntry } from "./protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +const url = (slug: string): VersionedUrl => + `https://example.com/types/entity-type/${slug}/v/1` as VersionedUrl; + +/** Recover the HSL hue (degrees) from an RGB colour, to assert "hue by root". */ +function hueOf(color: readonly number[]): number { + const red = color[0]! / 255; + const green = color[1]! / 255; + const blue = color[2]! / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + if (delta < 1e-6) { + return 0; + } + let hue: number; + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + hue *= 60; + return hue < 0 ? hue + 360 : hue; +} + +function buildRegistry(): { + registry: TypeRegistry; + company: TypeIdx; + customer: TypeIdx; + supplier: TypeIdx; + person: TypeIdx; +} { + const company = url("company"); + const customer = url("customer"); + const supplier = url("supplier"); + const person = url("person"); + + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + { url: person, title: "Person", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + return { + registry, + company: registry.intern(company), + customer: registry.intern(customer), + supplier: registry.intern(supplier), + person: registry.intern(person), + }; +} + +describe("entity-style colour", () => { + it("picks the most specific type as the primary", () => { + const { registry, company, customer } = buildRegistry(); + expect(primaryTypeOfSet([company, customer], registry)).toBe(customer); + expect(primaryTypeOfSet([company], registry)).toBe(company); + }); + + it("gives siblings the SAME family hue but DIFFERENT shades", () => { + const { registry, customer, supplier } = buildRegistry(); + const customerColor = colorForType(customer, registry); + const supplierColor = colorForType(supplier, registry); + + // Same root (Company) → same hue; lightness jitter keeps them distinct. + expect(Math.abs(hueOf(customerColor) - hueOf(supplierColor))).toBeLessThan( + 5, + ); + expect(customerColor).not.toEqual(supplierColor); + }); + + it("gives different roots clearly different hues", () => { + const { registry, customer, person } = buildRegistry(); + const customerHue = hueOf(colorForType(customer, registry)); + const personHue = hueOf(colorForType(person, registry)); + expect(Math.abs(customerHue - personHue)).toBeGreaterThan(10); + }); + + it("gives link types enough hue separation to distinguish relationship lanes", () => { + const hasMember = url("has-member"); + const manages = url("manages"); + const registry = new TypeRegistry(); + registry.registerAll([ + { url: hasMember, title: "Has Member", allOfRefs: [] }, + { url: manages, title: "Manages", allOfRefs: [] }, + ]); + + const firstHue = hueOf( + edgeColorForType(registry.intern(hasMember), registry), + ); + const secondHue = hueOf( + edgeColorForType(registry.intern(manages), registry), + ); + + expect(Math.abs(firstHue - secondHue)).toBeGreaterThan(40); + }); + + it("is deterministic", () => { + const { registry, customer } = buildRegistry(); + expect(colorForType(customer, registry)).toEqual( + colorForType(customer, registry), + ); + }); + + it("gives a type the same colour regardless of type arrival order", () => { + // The reload-consistency bug: colour was keyed off arrival-order intern + // indices, so the same type drew a different colour depending on the order + // types streamed in. Two registries with the same types in opposite orders + // must now produce identical colours. + const company = url("company"); + const customer = url("customer"); + const person = url("person"); + + const forward = new TypeRegistry(); + forward.registerAll([ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + { url: person, title: "Person", allOfRefs: [] }, + ]); + + const reversed = new TypeRegistry(); + reversed.registerAll([ + { url: person, title: "Person", allOfRefs: [] }, + { url: company, title: "Company", allOfRefs: [] }, + { url: customer, title: "Customer", allOfRefs: [company] }, + ]); + + expect(colorForType(forward.intern(customer), forward)).toEqual( + colorForType(reversed.intern(customer), reversed), + ); + expect(colorForType(forward.intern(person), forward)).toEqual( + colorForType(reversed.intern(person), reversed), + ); + }); + + it("falls back to a neutral grey for an unknown type or root", () => { + const { registry } = buildRegistry(); + expect(colorForType(undefined, registry)).toEqual([126, 142, 160, 220]); + }); + + it("sizes by degree subtly and monotonically", () => { + const radius0 = radiusForDegree(0); + const radius5 = radiusForDegree(5); + const radius50 = radiusForDegree(50); + expect(radius0).toBeGreaterThan(0); + expect(radius5).toBeGreaterThan(radius0); + expect(radius50).toBeGreaterThan(radius5); + // Subtle: a degree-50 hub is not even 3× a leaf. + expect(radius50).toBeLessThan(radius0 * 3); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts new file mode 100644 index 00000000000..9c1180576af --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts @@ -0,0 +1,184 @@ +/** + * Per-entity visual encoding for the individual-entity (flat) tiers: colour by + * type and size by degree (see `LAYOUT-MODES.md` "Cross-cutting features"). + * + * Colour is hierarchy-aware: the hue comes from the entity's root type and the + * shade from how specific its actual type is (its depth in the `allOf` tree), so + * the type tree reads at a glance (every Company-rooted entity is a shade of one + * hue, a Customer a lighter shade than a bare Company) instead of N arbitrary + * palette colours. + * + * Hue is keyed off a STABLE colour slot the type registry assigns each type + * (sorted-batch by base URL, append-only), spread around the wheel by the golden + * angle. The slot -- not an arrival-order intern index -- is what makes a type's + * colour identical across reloads: it depends on the URL, not on the order types + * stream in. + * + * Size is by degree, subtly: a hub reads as a slightly larger dot. The radius is + * also the layout's non-overlap box, so hubs claim a little more room. + */ +import { extractBaseUrl } from "@blockprotocol/type-system"; + +import { graphColors, hslToRgb } from "../visual-style"; + +import type { Color } from "../frames"; +import type { TypeIdx } from "../ids"; +import type { TypeRegistry } from "./stores/type-registry"; + +/** Hue step between successive colour slots (degrees); well-separated, stable. */ +const GOLDEN_ANGLE_DEG = 137.508; +const HUE_SATURATION = 0.62; +/** + * Lightness encodes the type's place in its root's family: a depth gradient + * (deeper subtype -> lighter) plus a small per-type jitter so siblings at the + * same depth (Customer vs Supplier under Company) stay distinguishable. The hue + * is shared across the family, so the tree still reads at a glance. + */ +const ROOT_LIGHTNESS = 0.42; +const LIGHTNESS_PER_DEPTH = 0.045; +const SIBLING_JITTER = 0.045; +const MIN_LIGHTNESS = 0.32; +const MAX_LIGHTNESS = 0.68; +const MAX_SHADE_DEPTH = 4; +const DOT_ALPHA = 220; + +/** Base dot radius (world units) and the (subtle) per-degree growth factor. */ +const DOT_BASE_RADIUS = 4; +const DOT_DEGREE_K = 0.35; + +/** Fallback for an entity whose type/root can't be resolved (untyped, unsent). */ +const NEUTRAL: Color = [...graphColors.fallbackEntity]; + +/** + * Edges share the nodes' stable slot hues but with lower saturation and mid + * lightness, so a link reads as related to its type yet sits behind the dots. + */ +const EDGE_SATURATION = 0.58; +const EDGE_LIGHTNESS = 0.46; +const EDGE_ALPHA = 180; +const EDGE_NEUTRAL: Color = [...graphColors.fallbackEntity]; + +/** + * Colour for a FRONTIER node: a fetched entity that is a link endpoint of a query root but not + * itself a root. A cool, desaturated, semi-transparent grey so it reads as "ghosted -- click to + * expand", receding behind the fully-coloured roots and staying distinct from both the type hues + * and the focus dim. + */ +export const FRONTIER_COLOR: Color = [...graphColors.frontier]; + +/** + * Deterministic FNV-1a 32-bit hash of a string mapped to the unit interval + * [0, 1). Stable across reloads and sessions, so a value derived from a type's + * URL is identical every time, unlike an arrival-order intern index whose value + * depends on the order types stream in. + */ +/* eslint-disable no-bitwise */ +function hashUnit(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0) / 0x100000000; +} +/* eslint-enable no-bitwise */ + +/** + * Golden-angle hue (degrees) for a type's stable colour slot, or undefined when + * the type has no slot yet (its schema hasn't been registered). + */ +function slotHue( + typeIdx: TypeIdx | undefined, + types: TypeRegistry, +): number | undefined { + if (typeIdx === undefined) { + return undefined; + } + const slot = types.colorSlot(typeIdx); + return slot === undefined ? undefined : (slot * GOLDEN_ANGLE_DEG) % 360; +} + +/** + * The most specific type in a set: greatest `allOf` depth, ties broken by the + * smallest idx (deterministic). This is the type whose shade/icon best + * represents the entity (a `Customer`, not its `Company` ancestor). + */ +export function primaryTypeOfSet( + typeIdxs: Iterable, + types: TypeRegistry, +): TypeIdx | undefined { + let best: TypeIdx | undefined; + let bestDepth = -1; + for (const typeIdx of typeIdxs) { + const depth = types.get(typeIdx)?.depth ?? 0; + if ( + best === undefined || + depth > bestDepth || + (depth === bestDepth && typeIdx < best) + ) { + best = typeIdx; + bestDepth = depth; + } + } + return best; +} + +/** + * Hierarchy-aware colour for a (primary) type: hue from its root's colour slot, + * lightness shade from its depth. Falls back to a neutral grey when the type or + * its root is unknown. + */ +export function colorForType( + typeIdx: TypeIdx | undefined, + types: TypeRegistry, +): Color { + if (typeIdx === undefined) { + return NEUTRAL; + } + const info = types.get(typeIdx); + if (info === undefined) { + return NEUTRAL; + } + const hue = slotHue(info.rootIdxs[0], types); + if (hue === undefined) { + return NEUTRAL; + } + const depth = Math.min(info.depth, MAX_SHADE_DEPTH); + // Per-type jitter from the type's own base URL: separates same-depth siblings + // within the shared family hue. Hash-based, so it is stable across reloads. + const fraction = hashUnit(extractBaseUrl(info.url)); + const jitter = (fraction - 0.5) * 2 * SIBLING_JITTER; + const lightness = Math.min( + MAX_LIGHTNESS, + Math.max( + MIN_LIGHTNESS, + ROOT_LIGHTNESS + depth * LIGHTNESS_PER_DEPTH + jitter, + ), + ); + const [red, green, blue] = hslToRgb(hue, HUE_SATURATION, lightness); + return [red, green, blue, DOT_ALPHA]; +} + +/** + * Stable colour for an edge of a given (primary link) type: golden-angle hue + * from the LINK type's OWN colour slot. Link types all share the `Link` root, so + * keying off the root would collapse every edge to one colour; the own-type slot + * gives each link type a distinct hue, identical across reloads. Lower + * saturation / mid lightness than nodes so edges sit behind them. + */ +export function edgeColorForType( + typeIdx: TypeIdx | undefined, + types: TypeRegistry, +): Color { + const hue = slotHue(typeIdx, types); + if (hue === undefined) { + return EDGE_NEUTRAL; + } + const [red, green, blue] = hslToRgb(hue, EDGE_SATURATION, EDGE_LIGHTNESS); + return [red, green, blue, EDGE_ALPHA]; +} + +/** Subtle by-degree dot radius in world units: r = base·(1 + ln(1+deg)·k). */ +export function radiusForDegree(degree: number): number { + return DOT_BASE_RADIUS * (1 + Math.log(1 + degree) * DOT_DEGREE_K); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts new file mode 100644 index 00000000000..70451330646 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts @@ -0,0 +1,193 @@ +import { GraphWorker } from "./core/graph-worker"; + +/** + * Web worker entry point. Thin message dispatch to GraphWorker. + * + * The worker owns two emission channels, wired here to postMessage: + * - StructureFrame: rare (topology change), structured-clone with the small + * per-leaf edge-topology buffers transferred. + * - PositionsFrame: frequent while settling; its flat buffers are transferred + * (zero-copy) and held in a ref on the main thread, so nothing accumulates. + */ +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { MainToWorkerMessage, WorkerToMainMessage } from "./protocol"; + +const workerScope = globalThis as unknown as DedicatedWorkerGlobalScope; + +function post(message: WorkerToMainMessage, transfer?: Transferable[]): void { + workerScope.postMessage(message, transfer ?? []); +} + +function postStructure(frame: StructureFrame): void { + // The per-leaf edge-topology arrays are freshly built and owned by no one + // else, so transfer their backing buffers. + const transfer: Transferable[] = []; + for (const layer of frame.entityLayers) { + transfer.push(layer.internalEdges.buffer); + } + post({ type: "STRUCTURE_FRAME", frame }, transfer); +} + +function postPositions(frame: PositionsFrame): void { + const transfer: Transferable[] = [ + frame.clusterPositions.buffer, + frame.beziers.positions.buffer, + frame.beziers.colors.buffer, + frame.beziers.widths.buffer, + frame.beziers.clips.buffer, + frame.beziers.ids.buffer, + ]; + // Per-leaf fan-out endpoints are freshly built each tick, transfer them too. + for (const entry of frame.entityFanOut) { + transfer.push(entry.fanOut.buffer); + } + post({ type: "POSITIONS_FRAME", frame }, transfer); +} + +let worker: GraphWorker | undefined; + +let drainScheduled = false; + +function scheduleDrain(): void { + if (drainScheduled) { + return; + } + drainScheduled = true; + void Promise.resolve().then(() => { + drainScheduled = false; + if (!worker) { + return; + } + for (const request of worker.drainEmbeddingRequests()) { + post(request); + } + }); +} + +globalThis.onmessage = ({ data }: MessageEvent) => { + switch (data.type) { + case "INIT": { + try { + worker = new GraphWorker(data.config); + worker.registerTypes(data.typeSchemas, data.propertySchemas); + worker.onLayoutMessage = (msg) => post(msg); + worker.onStructureFrame = (frame) => postStructure(frame); + worker.onPositionsFrame = (frame) => postPositions(frame); + post({ type: "READY" }); + } catch (err) { + post({ type: "ERROR", message: String(err) }); + } + break; + } + + case "REGISTER_TYPES": { + if (!worker) { + post({ type: "ERROR", message: "Worker not initialized" }); + break; + } + + try { + worker.registerTypes(data.typeSchemas, data.propertySchemas); + // Types drive labels/colours; force a fresh hierarchical rebuild (a no-op + // for the flat tiers, which recompute colours from the registry anyway). + worker.commitStructure({ rebuildTree: true }); + } catch (err) { + post({ type: "ERROR", message: String(err) }); + } + break; + } + + case "INGEST_BATCH": { + if (!worker) { + post({ type: "ERROR", message: "Worker not initialized" }); + break; + } + + const t0 = performance.now(); + const deltas = worker.ingestBatch(data.entities); + const tIngest = performance.now(); + // The worker owns topology maintenance (cluster-tree vs flat layout), + // gated by mode, so a tree rebuild can't clear the flat layout. + worker.commitStructure({ deltas }); + // Re-style if an expand flipped an already-rendered frontier node to a root (hierarchical + // tier only; the flat tier already restyled in the commit). + worker.restyleIfRootsFlipped(); + const tCommit = performance.now(); + if (worker.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][ingest] +${data.entities.length} → ${worker.nodeCount} nodes, ` + + `${worker.linkCount} links | ` + + `ingest ${(tIngest - t0).toFixed(1)}ms ` + + `commit ${(tCommit - tIngest).toFixed(1)}ms`, + ); + } + scheduleDrain(); + break; + } + + case "VIEWPORT_CHANGED": { + if (!worker) { + break; + } + worker.handleViewport({ + zoom: data.zoom, + centerX: data.center[0], + centerY: data.center[1], + width: data.width, + height: data.height, + }); + scheduleDrain(); + break; + } + + case "EMBEDDING_CLUSTERING_RESULT": { + if (!worker) { + break; + } + worker.applyEmbeddingResult(data.clusterId, data.clusters); + worker.commitStructure(); + break; + } + + case "QUERY_EGO": { + if (!worker) { + break; + } + post({ + type: "EGO_RESULT", + requestId: data.requestId, + targets: worker.ego(data.entityIdx), + }); + break; + } + + case "QUERY_HIGHWAY_LINKS": { + if (!worker) { + break; + } + post({ + type: "HIGHWAY_LINKS_RESULT", + requestId: data.requestId, + linkEntityIdxs: worker.highwayLinks(data.laneId), + }); + break; + } + + case "SET_PINNED": { + if (!worker) { + break; + } + worker.pin(data.clusterId ?? undefined); + break; + } + + case "SET_HIGHLIGHT": { + if (!worker) { + break; + } + worker.setHighlight(data.entityIdxs); + break; + } + } +}; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts new file mode 100644 index 00000000000..cf19e06fe8c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts @@ -0,0 +1,493 @@ +/* eslint-disable id-length, no-param-reassign */ +/** + * Bubble ports: dedicated boundary points where edges enter or exit + * a cluster. For each pair of connected visible clusters, a port on + * each cluster faces the other. All edges between the pair are routed + * through these two ports, producing clean bundled paths. + * + * Port slotting ensures minimum angular separation between ports on + * the same cluster. When too many neighbors lie in similar directions, + * ports are merged into angular sectors. + * + * Hysteresis: port assignments are cached per (clusterId, neighbor set). + * Ports only recompute when the set of connected neighbors changes. + */ +import type { VizConfig } from "../../config"; +import type { Circle } from "../../geometry"; +import type { ClusterId } from "../../ids"; +import type { ClusterTree } from "../hierarchy/cluster-tree"; +/** Minimal interface for pair data needed by port computation. */ +export interface PairInfo { + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + readonly totalCount: number; + readonly byType: ReadonlyMap; +} + +export interface Port { + readonly clusterId: ClusterId; + readonly neighborId: ClusterId; + /** + * Every neighbor this port serves. For unmerged ports this is a + * single-element array equal to `[neighborId]`. For ports merged by + * angular sector it holds all neighbors collapsed into the sector, so + * lookups keyed by neighbor (e.g. entity fan-out feeders) can resolve + * every neighbor to its port instead of only the representative. + */ + readonly allNeighborIds: readonly ClusterId[]; + readonly angle: number; + readonly x: number; + readonly y: number; + readonly edgeCount: number; + readonly distinctTypes: number; +} + +interface RawPort { + readonly neighborId: ClusterId; + angle: number; + readonly edgeCount: number; + readonly distinctTypes: number; +} + +// Helpers (above callers) + +function makePort( + clusterId: ClusterId, + neighborId: ClusterId, + allNeighborIds: readonly ClusterId[], + angle: number, + circle: Circle, + padding: number, + edgeCount: number, + distinctTypes: number, +): Port { + const r = circle.radius + padding; + return { + clusterId, + neighborId, + allNeighborIds, + angle, + x: circle.x + r * Math.cos(angle), + y: circle.y + r * Math.sin(angle), + edgeCount, + distinctTypes, + }; +} + +/** A single port's reserved arc is capped here so one can't hog the rim. */ +const MAX_PORT_ARC = Math.PI / 3; + +/** + * Pool Adjacent Violators: the optimal monotone non-decreasing least-squares fit + * of `desired`, in O(n). With the cumulative reserved-arc offsets folded out + * (see {@link placePortsOnPerimeter}), placing ports at minimum total + * displacement subject to "stay in cyclic order, keep a gap apart" is isotonic + * regression, and PAVA solves it exactly. + */ +function poolAdjacentViolators(desired: readonly number[]): number[] { + const blocks: { sum: number; count: number }[] = []; + for (const value of desired) { + let block = { sum: value, count: 1 }; + while ( + blocks.length > 0 && + blocks[blocks.length - 1]!.sum / blocks[blocks.length - 1]!.count > + block.sum / block.count + ) { + const prev = blocks.pop()!; + block = { sum: prev.sum + block.sum, count: prev.count + block.count }; + } + blocks.push(block); + } + + const result: number[] = []; + for (const block of blocks) { + const mean = block.sum / block.count; + for (let i = 0; i < block.count; i++) { + result.push(mean); + } + } + return result; +} + +/** + * Place ports on the bubble's perimeter. Model: a port is a node constrained to + * the circle, free to slide along it. Each wants its ideal angle, the perimeter + * point nearest its target (straight toward the neighbor), which keeps the + * leader short and stops the edge cutting back through the bubble. Ports reserve + * an arc (proportional to lane count, so wider bundles get more room) and may + * not overlap; we slide them the minimum total amount to satisfy that while + * preserving their cyclic order, so leaders never cross. + * + * Theory: fix the cyclic order (cut the rim at the pair with the most free + * space) and fold out the cumulative reserved-arc offsets, then "minimise + * sum(placed - ideal)² s.t. placed stays ordered, >= a gap apart" is exactly + * isotonic regression, solved optimally by PAVA. It is the 1-D specialisation of + * the separation-constraint solve (VPSC) WebCola runs for node non-overlap, the + * same theory, applied to angles on the rim. + * + * `ports` is pre-sorted ascending by ideal angle; mutates `port.angle`. + */ +function placePortsOnPerimeter( + ports: RawPort[], + minArc: number, + maxArc: number, +): void { + const n = ports.length; + if (n < 2) { + return; + } + + // Reserved arc per port (proportional to lane count), clamped; scaled down + // together if they would overflow the rim, so they always fit (then they pack + // tight). + const arc = ports.map((port) => + Math.min(maxArc, Math.max(minArc, minArc * port.distinctTypes)), + ); + let total = 0; + for (const value of arc) { + total += value; + } + const budget = 2 * Math.PI * 0.98; + if (total > budget) { + const scale = budget / total; + for (let i = 0; i < n; i++) { + arc[i] = arc[i]! * scale; + } + } + + // Required centre-to-centre gap after port i (wrapping). + const gapAfter = (i: number): number => (arc[i]! + arc[(i + 1) % n]!) / 2; + + // Cut the cycle at the consecutive pair with the most slack, so no separation + // constraint spans the cut and the remainder is a linear chain. + let cutAfter = n - 1; + let bestSlack = -Infinity; + for (let i = 0; i < n; i++) { + const next = (i + 1) % n; + let gap = ports[next]!.angle - ports[i]!.angle; + if (next === 0) { + gap += 2 * Math.PI; + } + const slack = gap - gapAfter(i); + if (slack > bestSlack) { + bestSlack = slack; + cutAfter = i; + } + } + + // Linear order starting just after the cut; unwrap angles to increase. + const order: number[] = []; + for (let k = 0; k < n; k++) { + order.push((cutAfter + 1 + k) % n); + } + const theta: number[] = []; + let running = ports[order[0]!]!.angle; + theta.push(running); + for (let k = 1; k < n; k++) { + let angle = ports[order[k]!]!.angle; + while (angle < running) { + angle += 2 * Math.PI; + } + theta.push(angle); + running = angle; + } + + // Fold out cumulative gaps -> isotonic problem; PAVA; fold the gaps back in. + const prefix: number[] = [0]; + for (let k = 1; k < n; k++) { + prefix.push(prefix[k - 1]! + gapAfter(order[k - 1]!)); + } + const fitted = poolAdjacentViolators( + theta.map((value, k) => value - prefix[k]!), + ); + for (let k = 0; k < n; k++) { + ports[order[k]!]!.angle = fitted[k]! + prefix[k]!; + } +} + +/** + * Merge ports into angular sectors when there are too many for the + * available screen space. Each neighbor maps to its sector's merged port. + */ +function mergeByAngularSector( + ports: readonly RawPort[], + sectorCount: number, + clusterId: ClusterId, + circle: Circle, + padding: number, +): Map { + const sectorWidth = (2 * Math.PI) / sectorCount; + const sectors: RawPort[][] = Array.from({ length: sectorCount }, () => []); + + for (const port of ports) { + // Map angle from [-π, π] to [0, 2π) for sector assignment. + let normalized = port.angle + Math.PI; + if (normalized < 0) { + normalized += 2 * Math.PI; + } + if (normalized >= 2 * Math.PI) { + normalized -= 2 * Math.PI; + } + const sector = Math.min( + Math.floor(normalized / sectorWidth), + sectorCount - 1, + ); + sectors[sector]!.push(port); + } + + const result = new Map(); + + for (const group of sectors) { + if (group.length === 0) { + continue; + } + + // Circular mean angle. + let sinSum = 0; + let cosSum = 0; + let totalEdges = 0; + let totalTypes = 0; + for (const p of group) { + sinSum += Math.sin(p.angle); + cosSum += Math.cos(p.angle); + totalEdges += p.edgeCount; + totalTypes += p.distinctTypes; + } + const meanAngle = Math.atan2(sinSum / group.length, cosSum / group.length); + + const merged = makePort( + clusterId, + group[0]!.neighborId, + group.map((p) => p.neighborId), + meanAngle, + circle, + padding, + totalEdges, + totalTypes, + ); + + // All neighbors in this sector share the merged port. + for (const p of group) { + result.set(p.neighborId, merged); + } + } + + return result; +} + +/** + * Compute slotted ports for a single cluster, returning a map + * from each neighbor cluster ID to the port serving it. + */ +function slotPorts( + clusterId: ClusterId, + rawPorts: RawPort[], + circle: Circle, + config: VizConfig, +): Map { + if (rawPorts.length === 0) { + return new Map(); + } + + // Sort by angle for slotting. + rawPorts.sort((a, b) => a.angle - b.angle); + + // Zoom-independent slotting: a port's slot is a function of neighbor + // direction only, so ports never re-slot or reassign as the user pans/zooms + // (spec section 6.5.3 hysteresis). They recompute only when the neighbor set + // or cluster positions change, which the PortCache key captures. + const portCap = config.maxPortsPerCluster; + const minSepAngle = (2 * Math.PI) / portCap; + + if (rawPorts.length <= portCap) { + // Slide ports to their minimum-displacement, non-overlapping, order- + // preserving positions on the rim (ideal = straight toward each neighbor). + placePortsOnPerimeter(rawPorts, minSepAngle, MAX_PORT_ARC); + + const result = new Map(); + for (const p of rawPorts) { + result.set( + p.neighborId, + makePort( + clusterId, + p.neighborId, + [p.neighborId], + p.angle, + circle, + config.portPaddingWorld, + p.edgeCount, + p.distinctTypes, + ), + ); + } + return result; + } + + // Too many ports: merge by angular sector. + return mergeByAngularSector( + rawPorts, + portCap, + clusterId, + circle, + config.portPaddingWorld, + ); +} + +// Port hysteresis cache + +/** + * Caches port assignments per cluster, keyed by a composite signature + * that captures everything port positions depend on: the cluster's own + * circle, each neighbor's center, the neighbor set, and zoom. + * + * Ports are reused only when none of those inputs changed, preserving + * hysteresis without freezing ports at stale positions as the force + * layout moves clusters. + */ +export class PortCache { + readonly #cache = new Map< + ClusterId, + { readonly key: string; readonly ports: Map } + >(); + + get(clusterId: ClusterId, key: string): Map | undefined { + const entry = this.#cache.get(clusterId); + if (!entry) { + return undefined; + } + return entry.key === key ? entry.ports : undefined; + } + + set(clusterId: ClusterId, key: string, ports: Map): void { + this.#cache.set(clusterId, { key, ports }); + } + + clear(): void { + this.#cache.clear(); + } +} + +// Top-level port computation + +interface NeighborInfo { + readonly neighborId: ClusterId; + readonly edgeCount: number; + readonly distinctTypes: number; +} + +function addNeighborInfo( + map: Map, + from: ClusterId, + to: ClusterId, + edgeCount: number, + distinctTypes: number, +): void { + let list = map.get(from); + if (!list) { + list = []; + map.set(from, list); + } + list.push({ neighborId: to, edgeCount, distinctTypes }); +} + +/** + * Compute ports for all connected cluster pairs. Returns a map + * from pair key to { source port, target port }. + * + * Uses the PortCache for hysteresis: unchanged neighbor sets + * reuse cached port positions. + */ +export function computeAllPorts( + pairs: ReadonlyMap, + clusterTree: ClusterTree, + config: VizConfig, + cache: PortCache, +): Map { + // Collect per-cluster neighbor info. + const clusterNeighbors = new Map(); + + for (const pair of pairs.values()) { + addNeighborInfo( + clusterNeighbors, + pair.sourceId, + pair.targetId, + pair.totalCount, + pair.byType.size, + ); + addNeighborInfo( + clusterNeighbors, + pair.targetId, + pair.sourceId, + pair.totalCount, + pair.byType.size, + ); + } + + // Compute slotted ports for each cluster (with hysteresis). + const portsByCluster = new Map>(); + + for (const [clusterId, neighbors] of clusterNeighbors) { + const cluster = clusterTree.get(clusterId); + if (!cluster) { + continue; + } + + // Compute raw port angles and the cache signature in one pass. + const rawPorts: RawPort[] = []; + const sigParts: string[] = []; + for (const info of neighbors) { + const neighbor = clusterTree.get(info.neighborId); + if (!neighbor) { + continue; + } + + rawPorts.push({ + neighborId: info.neighborId, + angle: Math.atan2( + neighbor.circle.y - cluster.circle.y, + neighbor.circle.x - cluster.circle.x, + ), + edgeCount: info.edgeCount, + distinctTypes: info.distinctTypes, + }); + sigParts.push( + `${info.neighborId}@${neighbor.circle.x.toFixed(2)},${neighbor.circle.y.toFixed(2)}`, + ); + } + + sigParts.sort(); + // No zoom in the key: slotting is zoom-independent, so a pan/zoom never + // invalidates ports. Positions stay in the key so ports follow the layout + // while it settles, then stay put once it freezes. + const cacheKey = `${cluster.circle.x.toFixed(2)},${cluster.circle.y.toFixed(2)},${cluster.circle.radius.toFixed(2)}|${sigParts.join( + ";", + )}`; + + const cached = cache.get(clusterId, cacheKey); + if (cached) { + portsByCluster.set(clusterId, cached); + continue; + } + + const portMap = slotPorts(clusterId, rawPorts, cluster.circle, config); + cache.set(clusterId, cacheKey, portMap); + portsByCluster.set(clusterId, portMap); + } + + // Build pair key -> (source port, target port) map. + const result = new Map< + string, + { readonly source: Port; readonly target: Port } + >(); + + for (const [pairKey, pair] of pairs) { + const sourcePort = portsByCluster.get(pair.sourceId)?.get(pair.targetId); + const targetPort = portsByCluster.get(pair.targetId)?.get(pair.sourceId); + + if (sourcePort && targetPort) { + result.set(pairKey, { source: sourcePort, target: targetPort }); + } + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts new file mode 100644 index 00000000000..8ab706587dd --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts @@ -0,0 +1,863 @@ +/** + * Edge aggregation: classifies stored links under the current LOD cut + * into aggregate (between clusters), individual (between visible entities), + * or hidden (internal to collapsed clusters). + * + * The rendered edge set is the quotient multigraph of stored links under + * the visible-owner equivalence relation induced by the LOD cut. + * + * Invariants: + * 1. Every stored link is classified as exactly one of + * aggregate / individual / hidden (no double counting). + * 2. For every aggregate edge (A, B, type), its count equals + * the number of links whose endpoint visible owners are A and B + * with that TypeSetIdx. + * 3. Visual keys are based on semantic identity (cluster pair + type), + * not array order. + * 4. Even when rendering a collapsed edge, exact byType counts are kept. + * 5. Aggregates are additive: affected links can be subtracted and + * re-added exactly for incremental updates. + */ +import { PairKey, VisualEdgeKey } from "../../ids"; +import { graphColors } from "../../visual-style"; +import { edgeColorForType, primaryTypeOfSet } from "../entity-style"; + +import type { VizConfig } from "../../config"; +import type { Color } from "../../frames"; +import type { ClusterId, EntityIdx, TypeSetIdx } from "../../ids"; +import type { ClusterNode, ClusterTree } from "../hierarchy/cluster-tree"; +import type { LodItem } from "../hierarchy/lod"; +import type { LinkStore } from "../stores/link-store"; +import type { TypeRegistry } from "../stores/type-registry"; +import type { TypeSetGroup, TypeSetStore } from "../stores/type-set-store"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +// Color / width / label helpers + +export function widthForCount(count: number): number { + return Math.max(1, Math.min(8, 1 + Math.log2(count + 1))); +} + +export function typeLabelForGroup( + group: TypeSetGroup | undefined, + types: TypeRegistry, +): string { + if (!group) { + return "Unknown"; + } + + const titles: string[] = []; + for (const typeIdx of group.directTypeIdxs) { + const info = types.get(typeIdx); + if (info) { + titles.push(info.title); + } + } + + return titles.length > 0 ? titles.join(" \u00d7 ") : "Unknown"; +} + +/** + * The single link type's VersionedUrl for a type-set group, or `null` if the group covers more + * than one type (a rollup). A lane is single-type by definition, so its group has one direct type; + * shipping its URL lets the main thread resolve the icon/title from the closed type schema it + * already holds, without the worker shipping any rich type data. + */ +export function typeUrlForGroup( + group: TypeSetGroup | undefined, + types: TypeRegistry, +): VersionedUrl | null { + if (!group) { + return null; + } + let only: VersionedUrl | null = null; + let seen = 0; + for (const typeIdx of group.directTypeIdxs) { + const url = types.getUrl(typeIdx); + if (url === undefined) { + continue; + } + seen += 1; + if (seen > 1) { + return null; + } + only = url; + } + return only; +} + +/** + * The reverse (target -> source) label for a type-set group: each type's inverse title ("Member + * Of") in place of its forward title ("Has Member"), so a reverse lane reads correctly. Falls back + * to the forward title for a type with no inverse. + */ +export function inverseLabelForGroup( + group: TypeSetGroup | undefined, + types: TypeRegistry, +): string { + if (!group) { + return "Unknown"; + } + + const titles: string[] = []; + for (const typeIdx of group.directTypeIdxs) { + const info = types.get(typeIdx); + if (info) { + titles.push(info.inverseTitle ?? info.title); + } + } + + return titles.length > 0 ? titles.join(" × ") : "Unknown"; +} + +// Cut index + +function collectEntityOwnership( + node: ClusterNode, + typeSets: TypeSetStore, + ownerId: ClusterId, + result: Map, +): void { + if (node.membership.source === "direct") { + for (const idx of node.membership.members.subarray()) { + result.set(idx as number, ownerId); + } + } else { + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + for (const idx of group.entityIdxs) { + result.set(idx as number, ownerId); + } + } + } + } + + // Rollup/family nodes carry no direct membership of their own. + // When collapsed into a single block, they must still own all + // entities in their subtree, otherwise those links are dropped. + for (const child of node.children) { + collectEntityOwnership(child, typeSets, ownerId, result); + } +} + +/** + * Maps every entity to its owning cluster at the current LOD level. + * + * Built from all clusters in the tree (not viewport-filtered). + * The viewport LOD cut determines which clusters are "open" (showing + * children or entities). Off-screen clusters default to "cluster" mode. + * Frustum culling is left to the presentation layer (Deck.gl). + */ +export class CutIndex { + readonly #entityModeIds: ReadonlySet; + readonly #containerIds: ReadonlySet; + readonly #entityOwner: ReadonlyMap; + + constructor( + viewportCut: readonly LodItem[], + clusterTree: ClusterTree, + typeSets: TypeSetStore, + ) { + const blockIds = new Set(); + const entityModeIds = new Set(); + const containerIds = new Set(); + const entityOwner = new Map(); + + // Index viewport cut by cluster ID for mode lookup. + const cutModes = new Map(); + for (const item of viewportCut) { + cutModes.set(item.clusterId, item.mode); + } + + // Walk all clusters in the tree, using viewport cut modes + // where available, defaulting to "cluster" for off-screen nodes. + this.#walkTree( + clusterTree.root, + cutModes, + typeSets, + blockIds, + entityModeIds, + containerIds, + entityOwner, + ); + + this.#entityModeIds = entityModeIds; + this.#containerIds = containerIds; + this.#entityOwner = entityOwner; + } + + /** Clusters in "children" mode (showing sub-clusters). */ + get containerIds(): ReadonlySet { + return this.#containerIds; + } + + /** + * Recursively walk the cluster tree. "children" mode means + * the node is a container: recurse into children. Any other mode + * (or absent from cut) means this node is a block that owns + * all its entities. + */ + #walkTree( + node: ClusterNode, + cutModes: ReadonlyMap, + typeSets: TypeSetStore, + blockIds: Set, + entityModeIds: Set, + containerIds: Set, + entityOwner: Map, + ): void { + if (node.kind === "root") { + for (const child of node.children) { + this.#walkTree( + child, + cutModes, + typeSets, + blockIds, + entityModeIds, + containerIds, + entityOwner, + ); + } + return; + } + + const mode = cutModes.get(node.id); + + // "children" mode: this node is a container. + if (mode === "children" && node.children.length > 0) { + containerIds.add(node.id); + for (const child of node.children) { + this.#walkTree( + child, + cutModes, + typeSets, + blockIds, + entityModeIds, + containerIds, + entityOwner, + ); + } + return; + } + + // Block: this node owns all its entities. + blockIds.add(node.id); + + if (mode === "entities" || mode === "entities-pending") { + entityModeIds.add(node.id); + } + + collectEntityOwnership(node, typeSets, node.id, entityOwner); + } + + get size(): number { + return this.#entityOwner.size; + } + + ownerOf(entityIdx: number): ClusterId | undefined { + return this.#entityOwner.get(entityIdx); + } + + isEntityMode(clusterId: ClusterId): boolean { + return this.#entityModeIds.has(clusterId); + } + + get entityModeIds(): ReadonlySet { + return this.#entityModeIds; + } + + /** Iterate all (entityIdx, ownerId) pairs for incremental diffing. */ + *entries(): IterableIterator<[number, ClusterId]> { + yield* this.#entityOwner; + } +} + +// Pair key + +const SEP = "\u001f"; + +export function makePairKey( + a: ClusterId, + b: ClusterId, +): { + readonly key: PairKey; + readonly sourceId: ClusterId; + readonly targetId: ClusterId; +} { + if (a > b) { + return { key: PairKey(`${b}${SEP}${a}`), sourceId: b, targetId: a }; + } + return { key: PairKey(`${a}${SEP}${b}`), sourceId: a, targetId: b }; +} + +// Internal mutable aggregation types + +interface TypeAggregation { + readonly typeSetIdx: TypeSetIdx; + /** + * Links flowing from the lower-sorted cluster ID to the higher one, + * matching PairKey's sort order. + */ + forwardCount: number; + /** Links flowing in the opposite direction. */ + reverseCount: number; + /** + * The link entities counted in `forwardCount`. Maintained in lockstep with + * the count (added on +1, removed on -1), so a clicked highway lane can + * resolve, on demand, to the exact set of links it aggregates. + */ + readonly forwardLinks: Set; + /** The link entities counted in `reverseCount` (mirror of `forwardLinks`). */ + readonly reverseLinks: Set; +} + +interface MutablePairAggregation { + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + readonly byType: Map; + totalCount: number; +} + +interface StoredIndividualEdge { + readonly linkIdx: number; + readonly ownerClusterId: ClusterId; + readonly leftEntityIdx: number; + readonly rightEntityIdx: number; + readonly typeSetIdx: TypeSetIdx; +} + +// Visual edge types (spec 6.2) + +interface ClusterEndpointRef { + readonly kind: "cluster"; + readonly id: ClusterId; +} + +interface EntityEndpointRef { + readonly kind: "entity"; + readonly entityIdx: number; + readonly ownerClusterId: ClusterId; +} + +/** + * Direction of an aggregate lane relative to PairKey's sort order. + * "forward" = links from the lower-sorted cluster to the higher one, + * "reverse" = the opposite, "both" = a collapsed lane mixing directions. + */ +export type EdgeDirection = "forward" | "reverse" | "both"; + +export interface AggregatedVisualEdge { + readonly kind: "aggregate"; + readonly visualKey: VisualEdgeKey; + readonly source: ClusterEndpointRef; + readonly target: ClusterEndpointRef; + readonly pairKey: PairKey; + readonly typeSetIdx: TypeSetIdx | undefined; + /** + * The lane's single link type as a VersionedUrl, when it has exactly one (a lane is single-type + * by definition). `null` for a multi-type rollup (`collapsed`). The main thread resolves the + * type's icon + title from the closed type schema it already holds -- the worker ships only the + * identity, never rich type data (INTERACTION.md: worker computes, main thread presents). + */ + readonly typeId: VersionedUrl | null; + readonly direction: EdgeDirection; + readonly collapsed: boolean; + readonly count: number; + readonly totalPairCount: number; + readonly distinctTypeCount: number; + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; + /** + * Stable per-commit id of this lane: its index in the final + * `EdgeFrame.visualEdges` array. Carried out to the rendered bezier segments + * (their `id`) so a clicked highway segment maps back to this lane, and used + * to index `StructureFrame.highwayLanes`. + */ + readonly laneId: number; + /** + * The link entities this lane aggregates. A forward lane carries the + * forward links, a reverse lane the reverse, a collapsed/"both" lane the + * union of both. Lets a clicked highway resolve to its underlying links. + */ + readonly linkEntityIdxs: readonly EntityIdx[]; +} + +export interface IndividualVisualEdge { + readonly kind: "individual"; + readonly visualKey: VisualEdgeKey; + readonly source: EntityEndpointRef; + readonly target: EntityEndpointRef; + readonly linkIdx: number; + readonly typeSetIdx: TypeSetIdx; + readonly count: 1; + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; +} + +export type VisualEdge = AggregatedVisualEdge | IndividualVisualEdge; + +export interface EdgeFrame { + readonly visualEdges: readonly VisualEdge[]; + readonly exactLogicalEdgeCount: number; + readonly renderedLogicalEdgeCount: number; + readonly omittedLogicalEdgeCount: number; + readonly truncated: boolean; +} + +// Pair explosion: convert raw aggregation into visual edges + +function explodePair( + pairKey: PairKey, + pair: MutablePairAggregation, + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, +): AggregatedVisualEdge[] { + const typeAggs = [...pair.byType.values()].sort( + (a, b) => + b.forwardCount + b.reverseCount - (a.forwardCount + a.reverseCount) || + (a.typeSetIdx as number) - (b.typeSetIdx as number), + ); + + const source: ClusterEndpointRef = { kind: "cluster", id: pair.sourceId }; + const target: ClusterEndpointRef = { kind: "cluster", id: pair.targetId }; + + if (typeAggs.length > config.maxParallelEdgeTypes) { + // A collapsed/"both" lane carries the union of both directions' links. + const collapsedLinks: EntityIdx[] = []; + for (const agg of typeAggs) { + collapsedLinks.push(...agg.forwardLinks, ...agg.reverseLinks); + } + return [ + { + kind: "aggregate", + visualKey: VisualEdgeKey(`agg:${pairKey}:__collapsed__`), + source, + target, + pairKey, + typeSetIdx: undefined, + typeId: null, + direction: "both", + collapsed: true, + count: pair.totalCount, + totalPairCount: pair.totalCount, + distinctTypeCount: typeAggs.length, + color: [...graphColors.collapsedEdge], + widthWorld: widthForCount(pair.totalCount), + typeLabel: `${typeAggs.length} link types`, + // Assigned once the final visualEdges order is known (#buildFrame). + laneId: -1, + linkEntityIdxs: collapsedLinks, + }, + ]; + } + + // A type with links in both directions becomes two separate lanes + // (one per direction), each sized by its own count. + const edges: AggregatedVisualEdge[] = []; + for (const agg of typeAggs) { + const group = typeSets.getByIdx(agg.typeSetIdx); + const typeLabel = typeLabelForGroup(group, types); + const inverseLabel = inverseLabelForGroup(group, types); + const typeId = typeUrlForGroup(group, types); + const color = edgeColorForType( + group ? primaryTypeOfSet(group.directTypeIdxs, types) : undefined, + types, + ); + + if (agg.forwardCount > 0) { + edges.push({ + kind: "aggregate", + visualKey: VisualEdgeKey( + `agg:${pairKey}:${agg.typeSetIdx as number}:forward`, + ), + source, + target, + pairKey, + typeSetIdx: agg.typeSetIdx, + typeId, + direction: "forward", + collapsed: false, + count: agg.forwardCount, + totalPairCount: pair.totalCount, + distinctTypeCount: typeAggs.length, + color, + widthWorld: widthForCount(agg.forwardCount), + typeLabel, + // Assigned once the final visualEdges order is known (#buildFrame). + laneId: -1, + linkEntityIdxs: Array.from(agg.forwardLinks), + }); + } + + if (agg.reverseCount > 0) { + edges.push({ + kind: "aggregate", + visualKey: VisualEdgeKey( + `agg:${pairKey}:${agg.typeSetIdx as number}:reverse`, + ), + source, + target, + pairKey, + typeSetIdx: agg.typeSetIdx, + typeId, + direction: "reverse", + collapsed: false, + count: agg.reverseCount, + totalPairCount: pair.totalCount, + distinctTypeCount: typeAggs.length, + color, + widthWorld: widthForCount(agg.reverseCount), + typeLabel: inverseLabel, + // Assigned once the final visualEdges order is known (#buildFrame). + laneId: -1, + linkEntityIdxs: Array.from(agg.reverseLinks), + }); + } + } + + return edges; +} + +// Edge aggregator + +/** + * Maintains edge aggregation state across frames. Supports + * incremental updates: when the LOD cut changes, only links + * whose visible owner changed are reclassified. + * + * Falls back to full recomputation when >35% of entities + * changed owners, or on first run / after reset. + */ +export class EdgeAggregator { + readonly #pairs = new Map(); + readonly #individuals = new Map(); + #hiddenCount = 0; + #previousCutIndex: CutIndex | undefined; + + reset(): void { + this.#pairs.clear(); + this.#individuals.clear(); + this.#hiddenCount = 0; + this.#previousCutIndex = undefined; + } + + /** + * Update aggregation for a new LOD cut. Uses incremental + * reclassification when possible, full recomputation otherwise. + */ + update( + cutIndex: CutIndex, + linkStore: LinkStore, + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + ): EdgeFrame { + if (!this.#previousCutIndex) { + this.#fullRecompute(cutIndex, linkStore); + } else { + const changedEntities = this.#findChangedEntities(cutIndex); + const changeRatio = changedEntities.size / Math.max(1, cutIndex.size); + + if (changeRatio > 0.35) { + this.#fullRecompute(cutIndex, linkStore); + } else if (changedEntities.size > 0) { + this.#incrementalUpdate(changedEntities, cutIndex, linkStore); + } + } + + this.#previousCutIndex = cutIndex; + return this.#buildFrame(typeSets, types, config); + } + + // Accessors for edge-geometry fan-out + + get pairs(): ReadonlyMap { + return this.#pairs; + } + + /** + * Classify a link and apply its contribution (sign = +1) + * or undo it (sign = -1). + */ + #applyLink( + linkIdx: number, + leftIdx: number, + rightIdx: number, + typeSetIdx: TypeSetIdx, + linkEntityIdx: EntityIdx, + cutIndex: CutIndex, + sign: 1 | -1, + ): void { + if (leftIdx === -1 || rightIdx === -1) { + this.#hiddenCount += sign; + return; + } + + const leftOwner = cutIndex.ownerOf(leftIdx); + const rightOwner = cutIndex.ownerOf(rightIdx); + + if (!leftOwner || !rightOwner) { + this.#hiddenCount += sign; + return; + } + + // Same visible owner: individual (if entity mode) or hidden. + if (leftOwner === rightOwner) { + if (cutIndex.isEntityMode(leftOwner)) { + if (sign === 1) { + this.#individuals.set(linkIdx, { + linkIdx, + ownerClusterId: leftOwner, + leftEntityIdx: leftIdx, + rightEntityIdx: rightIdx, + typeSetIdx, + }); + } else { + this.#individuals.delete(linkIdx); + } + } else { + this.#hiddenCount += sign; + } + return; + } + + // Different visible owners: aggregate. + const { key, sourceId, targetId } = makePairKey(leftOwner, rightOwner); + + // The link flows from leftOwner (source) to rightOwner (target). + // "forward" means that flow matches the PairKey sort order, i.e. + // the link's source owner is the lower-sorted cluster. + const forward = leftOwner === sourceId; + + if (sign === 1) { + let pair = this.#pairs.get(key); + if (!pair) { + pair = { sourceId, targetId, byType: new Map(), totalCount: 0 }; + this.#pairs.set(key, pair); + } + + let typeAgg = pair.byType.get(typeSetIdx as number); + if (!typeAgg) { + typeAgg = { + typeSetIdx, + forwardCount: 0, + reverseCount: 0, + forwardLinks: new Set(), + reverseLinks: new Set(), + }; + pair.byType.set(typeSetIdx as number, typeAgg); + } + if (forward) { + typeAgg.forwardCount++; + typeAgg.forwardLinks.add(linkEntityIdx); + } else { + typeAgg.reverseCount++; + typeAgg.reverseLinks.add(linkEntityIdx); + } + pair.totalCount++; + } else { + const pair = this.#pairs.get(key); + if (pair) { + const typeAgg = pair.byType.get(typeSetIdx as number); + if (typeAgg) { + if (forward) { + typeAgg.forwardCount--; + typeAgg.forwardLinks.delete(linkEntityIdx); + } else { + typeAgg.reverseCount--; + typeAgg.reverseLinks.delete(linkEntityIdx); + } + if (typeAgg.forwardCount <= 0 && typeAgg.reverseCount <= 0) { + pair.byType.delete(typeSetIdx as number); + } + } + pair.totalCount--; + if (pair.totalCount <= 0) { + this.#pairs.delete(key); + } + } + } + } + + #fullRecompute(cutIndex: CutIndex, linkStore: LinkStore): void { + this.#pairs.clear(); + this.#individuals.clear(); + this.#hiddenCount = 0; + + for (let i = 0; i < linkStore.count; i++) { + this.#applyLink( + i, + linkStore.getLeft(i) as number, + linkStore.getRight(i) as number, + linkStore.getTypeSetIdx(i), + linkStore.getEntityIdx(i), + cutIndex, + 1, + ); + } + } + + #incrementalUpdate( + changedEntities: Set, + newCutIndex: CutIndex, + linkStore: LinkStore, + ): void { + const oldCutIndex = this.#previousCutIndex!; + const processedLinks = new Set(); + + for (const entityIdx of changedEntities) { + const links = linkStore.linksForEntity(entityIdx as EntityIdx); + for (const link of links) { + if (processedLinks.has(link.linkIdx)) { + continue; + } + processedLinks.add(link.linkIdx); + + const leftIdx = linkStore.getLeft(link.linkIdx) as number; + const rightIdx = linkStore.getRight(link.linkIdx) as number; + const typeSetIdx = linkStore.getTypeSetIdx(link.linkIdx); + const linkEntityIdx = linkStore.getEntityIdx(link.linkIdx); + + // Undo old classification, apply new classification. + this.#applyLink( + link.linkIdx, + leftIdx, + rightIdx, + typeSetIdx, + linkEntityIdx, + oldCutIndex, + -1, + ); + this.#applyLink( + link.linkIdx, + leftIdx, + rightIdx, + typeSetIdx, + linkEntityIdx, + newCutIndex, + 1, + ); + } + } + } + + #findChangedEntities(newCutIndex: CutIndex): Set { + const changed = new Set(); + const oldCutIndex = this.#previousCutIndex!; + + // Entities whose owner changed or who left the view. + for (const [entityIdx, oldOwner] of oldCutIndex.entries()) { + const newOwner = newCutIndex.ownerOf(entityIdx); + if (newOwner !== oldOwner) { + changed.add(entityIdx); + } else if ( + // Owner unchanged, but the owner flipped between entity mode + // and block mode (e.g. a leaf bubble zoomed open into "entities"). + // Internal links must move between `individual` and `hidden`. + oldCutIndex.isEntityMode(oldOwner) !== + newCutIndex.isEntityMode(oldOwner) + ) { + changed.add(entityIdx); + } + } + + // Entities that entered the view. + for (const [entityIdx] of newCutIndex.entries()) { + if (oldCutIndex.ownerOf(entityIdx) === undefined) { + changed.add(entityIdx); + } + } + + return changed; + } + + #buildFrame( + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + ): EdgeFrame { + const visualEdges: VisualEdge[] = []; + + // Explode pairs into per-type (or collapsed) aggregate edges. + for (const [pairKey, pair] of this.#pairs) { + const exploded = explodePair( + pairKey as PairKey, + pair, + typeSets, + types, + config, + ); + visualEdges.push(...exploded); + } + + // Individual edges. + for (const edge of this.#individuals.values()) { + const group = typeSets.getByIdx(edge.typeSetIdx); + visualEdges.push({ + kind: "individual", + visualKey: VisualEdgeKey(`link:${edge.linkIdx}`), + source: { + kind: "entity", + entityIdx: edge.leftEntityIdx, + ownerClusterId: edge.ownerClusterId, + }, + target: { + kind: "entity", + entityIdx: edge.rightEntityIdx, + ownerClusterId: edge.ownerClusterId, + }, + linkIdx: edge.linkIdx, + typeSetIdx: edge.typeSetIdx, + count: 1, + color: edgeColorForType( + group ? primaryTypeOfSet(group.directTypeIdxs, types) : undefined, + types, + ), + widthWorld: 1, + typeLabel: typeLabelForGroup(group, types), + }); + } + + // Compute metadata. + let aggregateTotal = 0; + for (const pair of this.#pairs.values()) { + aggregateTotal += pair.totalCount; + } + const exactLogicalEdgeCount = + aggregateTotal + this.#individuals.size + this.#hiddenCount; + + // Edge cap: keep highest-count edges if over budget. + const truncated = visualEdges.length > config.maxRenderedEdges; + if (truncated) { + visualEdges.sort((lhs, rhs) => rhs.count - lhs.count); + visualEdges.length = config.maxRenderedEdges; + } + + // The visualEdges order is now final. Stamp each aggregate lane with its + // index as a stable per-commit `laneId`: carried out to the rendered bezier + // segments and used to index `StructureFrame.highwayLanes`. + for (let i = 0; i < visualEdges.length; i++) { + const edge = visualEdges[i]!; + if (edge.kind === "aggregate") { + visualEdges[i] = { ...edge, laneId: i }; + } + } + + const renderedLogicalEdgeCount = visualEdges.reduce( + (sum, edge) => sum + edge.count, + 0, + ); + + return { + visualEdges, + exactLogicalEdgeCount, + renderedLogicalEdgeCount, + omittedLogicalEdgeCount: this.#hiddenCount, + truncated, + }; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts new file mode 100644 index 00000000000..2f8140ed9d9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts @@ -0,0 +1,1559 @@ +/* eslint-disable id-length */ +/** + * Edge geometry: hierarchical highway model. + * + * The edge rendering follows a highway metaphor: + * + * - Highway: a single curved path between two cluster ports. + * Each link type is a lane (parallel offset from center line). + * Lanes merge at endpoints via a funnel ramp. + * + * - Feeders: inside an open container, lines run from each + * sub-cluster (or entity) to the container's port, where they + * merge into the highway. Feeder width is proportional to + * the sub-cluster's contribution to the highway. + * + * This composes recursively: sub-clusters have their own ports, + * their feeders merge at the parent port, which connects to + * the highway. It's feeders all the way down until we reach + * the indivisible unit: individual entities. + * + * Container crossings: when an edge crosses a container boundary, + * it's split into a feeder (inside) and a shared highway (outside). + * Multiple children sharing the same container and external target + * merge into one highway, avoiding duplicate edge rendering. + */ + +import { + BEZIER_NO_LINK, + type Color, + type RenderBezierBuffers, + type RenderEdgeArrow, + type RenderEdgeLabel, +} from "../../frames"; +import { makePairKey, widthForCount } from "./edge-aggregation"; + +import type { VizConfig } from "../../config"; +import type { Circle } from "../../geometry"; +import type { ClusterId, PairKey } from "../../ids"; +import type { ClusterTree } from "../hierarchy/cluster-tree"; +import type { Port } from "./bubble-ports"; +import type { + AggregatedVisualEdge, + CutIndex, + EdgeFrame, +} from "./edge-aggregation"; + +interface Waypoint { + readonly x: number; + readonly y: number; + readonly angle: number; +} + +interface CubicCurve { + readonly p0: readonly [number, number]; + readonly p1: readonly [number, number]; + readonly p2: readonly [number, number]; + readonly p3: readonly [number, number]; +} + +// Flat-array segment accumulator + +const FLOATS_PER_SEGMENT = 8; +const BYTES_PER_COLOR = 4; +/** Two clip circles per segment: `(cx, cy, signedRadius)` x 2 (one per end). */ +const FLOATS_PER_CLIP = 6; +const EDGE_LABEL_TEXT_WIDTH_EM = 0.58; +/** Keep labels as a lane annotation, not a banner that consumes the whole chord. */ +const EDGE_LABEL_MAX_CHORD_FRACTION = 0.68; +/** Approximate the render layer's horizontal capsule padding in text-size units. */ +const EDGE_LABEL_HORIZONTAL_PADDING_EM = 1.6; +/** Approximate the render layer's vertical capsule padding in text-size units. */ +const EDGE_LABEL_VERTICAL_PADDING_EM = 0.46; + +/** + * A circle that erases the edge on one side of itself in the fragment shader, so + * the edge ends flush on a bubble's perimeter rather than poking through it (the + * round SDF cap overshoots the wall, and since the edge layer is translucent the + * overshoot shows on both sides, draw order can't hide it, only a true clip). + * `signedRadius > 0` erases inside the circle (a highway ending on a bubble's + * outer wall); `< 0` erases outside (a feeder ending on its container's inner + * wall); `0` (or omitted) means no clip on that end. + */ +export interface ClipCircle { + readonly x: number; + readonly y: number; + readonly signedRadius: number; +} + +/** Clip that ends an edge flush on a bubble's outer wall (erase inside it), for + * a highway/feeder that approaches a cluster from outside. */ +function clipInside(circle: Circle): ClipCircle { + return { x: circle.x, y: circle.y, signedRadius: circle.radius }; +} + +/** Clip that ends an edge flush on a container's inner wall (erase outside it), + * for a feeder that reaches its container's port from inside. */ +function clipOutside(circle: Circle): ClipCircle { + return { x: circle.x, y: circle.y, signedRadius: -circle.radius }; +} + +/** + * Append-only sink for cubic Bezier segments backed by flat typed arrays. + * + * Instead of producing one segment object per curve (thousands of + * short-lived objects + tuple arrays per frame), each segment is written + * directly into three parallel scratch buffers: + * + * - `positions`: 8 floats/segment (p0..p3, interleaved as vec2 pairs) + * - `colors`: 4 bytes/segment (r, g, b, a) + * - `widths`: 1 float/segment (px) + * + * The buffers are owned by the worker and reused across frames: `reset()` + * rewinds the write cursor, and capacity only ever grows (never shrinks). + * Because the underlying buffers are reused, `snapshot()` returns exact-sized + * *copies* whose `ArrayBuffer`s can be transferred to the main thread without + * detaching the scratch. + */ +export class BezierSegmentSink { + #positions: Float32Array; + #colors: Uint8Array; + #widths: Float32Array; + #clips: Float32Array; + #ids: Uint32Array; + #count = 0; + #capacity: number; + + constructor(initialCapacity = 1024) { + this.#capacity = Math.max(1, initialCapacity); + this.#positions = new Float32Array(this.#capacity * FLOATS_PER_SEGMENT); + this.#colors = new Uint8Array(this.#capacity * BYTES_PER_COLOR); + this.#widths = new Float32Array(this.#capacity); + this.#clips = new Float32Array(this.#capacity * FLOATS_PER_CLIP); + this.#ids = new Uint32Array(this.#capacity); + } + + /** Number of segments written since the last `reset()`. */ + get count(): number { + return this.#count; + } + + /** Rewind the write cursor. Retains allocated capacity. */ + reset(): void { + this.#count = 0; + } + + /** Append one segment. Grows the backing buffers if needed. `clipA`/`clipB` + * erase the edge near its two ends so it ends flush on a bubble wall. */ + push( + curve: CubicCurve, + color: Color, + width: number, + clipA?: ClipCircle, + clipB?: ClipCircle, + id: number = BEZIER_NO_LINK, + ): void { + if (this.#count >= this.#capacity) { + this.#grow(this.#count + 1); + } + + const i = this.#count; + const p = i * FLOATS_PER_SEGMENT; + const pos = this.#positions; + pos[p] = curve.p0[0]; + pos[p + 1] = curve.p0[1]; + pos[p + 2] = curve.p1[0]; + pos[p + 3] = curve.p1[1]; + pos[p + 4] = curve.p2[0]; + pos[p + 5] = curve.p2[1]; + pos[p + 6] = curve.p3[0]; + pos[p + 7] = curve.p3[1]; + + const c = i * BYTES_PER_COLOR; + const colors = this.#colors; + colors[c] = color[0]; + colors[c + 1] = color[1]; + colors[c + 2] = color[2]; + colors[c + 3] = color[3]; + + this.#widths[i] = width; + + const k = i * FLOATS_PER_CLIP; + const clips = this.#clips; + clips[k] = clipA?.x ?? 0; + clips[k + 1] = clipA?.y ?? 0; + clips[k + 2] = clipA?.signedRadius ?? 0; + clips[k + 3] = clipB?.x ?? 0; + clips[k + 4] = clipB?.y ?? 0; + clips[k + 5] = clipB?.signedRadius ?? 0; + + this.#ids[i] = id; + this.#count = i + 1; + } + + #grow(minCapacity: number): void { + let next = this.#capacity * 2; + while (next < minCapacity) { + next *= 2; + } + + const positions = new Float32Array(next * FLOATS_PER_SEGMENT); + positions.set(this.#positions); + const colors = new Uint8Array(next * BYTES_PER_COLOR); + colors.set(this.#colors); + const widths = new Float32Array(next); + widths.set(this.#widths); + const clips = new Float32Array(next * FLOATS_PER_CLIP); + clips.set(this.#clips); + const ids = new Uint32Array(next); + ids.set(this.#ids); + + this.#positions = positions; + this.#colors = colors; + this.#widths = widths; + this.#clips = clips; + this.#ids = ids; + this.#capacity = next; + } + + /** + * Produce exact-sized, transferable copies of the current contents. The + * scratch buffers are left intact (and detached-safe) for the next frame. + */ + snapshot(): RenderBezierBuffers { + const count = this.#count; + return { + positions: this.#positions.slice(0, count * FLOATS_PER_SEGMENT), + colors: this.#colors.slice(0, count * BYTES_PER_COLOR), + widths: this.#widths.slice(0, count), + clips: this.#clips.slice(0, count * FLOATS_PER_CLIP), + ids: this.#ids.slice(0, count), + segmentCount: count, + }; + } +} + +/** + * Minimum curve "bend" injected when the natural control points are + * (near-)collinear, expressed so that the resulting midpoint sag is a + * gentle fraction of the chord. Keeps a smooth, deterministic arc instead + * of degenerating to a straight line when two ports face each other + * directly (MANIFESTO "Avoiding intermediate cluster intersections", + * approach C). The bend metric below equals (p1 + p2) perpendicular + * deviation; the curve's midpoint sag is (3/8) of it. + */ +const COLLINEAR_BEND_FRACTION = 0.15; + +/** Common/world gap between snug-packed parallel lanes. 0 = touching. */ +const LANE_GAP_WORLD = 1; + +/** + * Cubic Bezier between two waypoints. Both control points extend + * outward along each waypoint's outward normal. Tension clamped + * to 45% of chord length to prevent overshoot. + * + * When the two handles are (near-)collinear with the chord (the common case, + * since ports aim straight at each other) the cubic degenerates to a line. A + * perpendicular bow is then injected toward the chord's left normal, ramping + * smoothly to zero once the natural curvature is large enough. The side is + * derived purely from chord direction, so: + * - it is continuous (no snap) as the layout settles, and crucially does not + * flip when an LOD change swaps the highway endpoints (the old per-pair + * hash flipped, which read as a jarring jump); + * - opposing flows separate for free: A->B and B->A have opposite chords, so + * they bow to opposite sides instead of overlapping. + */ +function cubicBetweenWaypoints( + from: Waypoint, + to: Waypoint, + rawTension: number, + bow = true, +): CubicCurve { + const chordX = to.x - from.x; + const chordY = to.y - from.y; + const chord = Math.hypot(chordX, chordY); + const tension = Math.min(rawTension, 0.45 * chord); + + let p1x = from.x + Math.cos(from.angle) * tension; + let p1y = from.y + Math.sin(from.angle) * tension; + let p2x = to.x + Math.cos(to.angle) * tension; + let p2y = to.y + Math.sin(to.angle) * tension; + + if (bow && chord > 1e-6) { + // Unit normal to the chord (chord rotated +90 degrees): the consistent bow side. + const nx = -chordY / chord; + const ny = chordX / chord; + + // Signed perpendicular deviation of each handle from the chord. Both + // are ~0 exactly when the curve has degenerated to a straight line. + const perp1 = (p1x - from.x) * nx + (p1y - from.y) * ny; + const perp2 = (p2x - to.x) * nx + (p2y - to.y) * ny; + + const bendMetric = perp1 + perp2; + const target = COLLINEAR_BEND_FRACTION * chord; + + // collinearity in [0, 1]: 1 = perfectly collinear, 0 = already curved + // enough. The injected offset ramps to zero at the threshold, so the final + // bend is continuous in the natural curvature. + const collinearity = 1 - Math.min(1, Math.abs(bendMetric) / target); + if (collinearity > 0) { + // Split the bow equally across both handles for a symmetric arc. + const dq = (target * collinearity) / 2; + p1x += dq * nx; + p1y += dq * ny; + p2x += dq * nx; + p2y += dq * ny; + } + } + + return { + p0: [from.x, from.y], + p1: [p1x, p1y], + p2: [p2x, p2y], + p3: [to.x, to.y], + }; +} + +// Container hierarchy analysis + +interface ContainerCrossing { + readonly containerId: ClusterId; + readonly circle: Circle; +} + +interface HierarchyInfo { + /** Source-side containers (inner -> outer), excluding shared. */ + readonly sourceContainers: readonly ContainerCrossing[]; + /** Target-side containers (inner -> outer), excluding shared. */ + readonly targetContainers: readonly ContainerCrossing[]; +} + +/** + * Find container boundaries between source and target, split by side. + */ +export function analyzeHierarchy( + sourceId: ClusterId, + targetId: ClusterId, + clusterTree: ClusterTree, + containerIds: ReadonlySet, +): HierarchyInfo { + const sourceContainers: ContainerCrossing[] = []; + let node = clusterTree.get(sourceId); + while (node?.parent) { + if (containerIds.has(node.parent.id)) { + sourceContainers.push({ + containerId: node.parent.id, + circle: node.parent.circle, + }); + } + node = node.parent; + } + + const targetContainers: ContainerCrossing[] = []; + node = clusterTree.get(targetId); + while (node?.parent) { + if (containerIds.has(node.parent.id)) { + targetContainers.push({ + containerId: node.parent.id, + circle: node.parent.circle, + }); + } + node = node.parent; + } + + const sourceSet = new Set(sourceContainers.map((cc) => cc.containerId)); + const sharedIds = new Set( + targetContainers + .filter((cc) => sourceSet.has(cc.containerId)) + .map((cc) => cc.containerId), + ); + + return { + sourceContainers: sourceContainers.filter( + (cc) => !sharedIds.has(cc.containerId), + ), + targetContainers: targetContainers.filter( + (cc) => !sharedIds.has(cc.containerId), + ), + }; +} + +export function containerBoundaryWaypoint( + circle: Circle, + towardX: number, + towardY: number, + padding: number, +): Waypoint { + const angle = Math.atan2(towardY - circle.y, towardX - circle.x); + const r = circle.radius + padding; + return { + x: circle.x + r * Math.cos(angle), + y: circle.y + r * Math.sin(angle), + angle, + }; +} + +// Merged type info for highways that combine multiple children's edges. + +/** + * Direction of a highway/feeder lane, normalized so that "forward" + * always means flow from the highway source side to the target side, + * regardless of each child pair's own PairKey sort order. + */ +type LaneDirection = "forward" | "reverse" | "both"; + +/** + * Normalize a directed aggregate edge to the highway orientation. + * + * `nearId` is the child cluster on the side we are merging from, and + * `side` says whether that side is the highway's source or target. + * An edge whose physical source is the near child flows away from the + * source side (highway "forward"); on the target side the test inverts. + * Collapsed edges carry no single direction and stay "both". + */ +function highwayDirection( + edge: AggregatedVisualEdge, + nearId: ClusterId, + side: "source" | "target", +): LaneDirection { + if (edge.direction === "both") { + return "both"; + } + // Physical "from" cluster of the link. + const fromId = edge.direction === "forward" ? edge.source.id : edge.target.id; + if (side === "source") { + return fromId === nearId ? "forward" : "reverse"; + } + return fromId === nearId ? "reverse" : "forward"; +} + +interface MergedLane { + readonly typeKey: number; + readonly count: number; + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; + readonly direction: LaneDirection; +} + +function mergeLanes( + children: readonly HighwayGroupChild[], + side: "source" | "target", +): MergedLane[] { + const byLane = new Map< + string, + { + count: number; + color: Color; + typeLabel: string; + direction: LaneDirection; + } + >(); + + for (const child of children) { + for (const edge of child.edges) { + const typeKey = (edge.typeSetIdx as number | undefined) ?? -1; + const direction = highwayDirection(edge, child.childId, side); + const key = `${typeKey}:${direction}`; + const existing = byLane.get(key); + if (existing) { + existing.count += edge.count; + } else { + byLane.set(key, { + count: edge.count, + color: edge.color, + typeLabel: edge.typeLabel, + direction, + }); + } + } + } + + return [...byLane.values()].map((info) => ({ + typeKey: 0, + count: info.count, + color: info.color, + widthWorld: widthForCount(info.count), + typeLabel: info.typeLabel, + direction: info.direction, + })); +} + +// Recursive feeders: child -> intermediate containers -> outermost port + +interface FeederTypeInfo { + count: number; + color: Color; + typeLabel: string; + readonly direction: LaneDirection; +} + +interface FeederSegment { + sourceId: ClusterId; + sourceCircle: Circle; + targetWp: Waypoint; + targetId: ClusterId; + /** + * Per (type, direction) counts and colors. Each entry becomes a + * colored lane, keyed by `${typeKey}:${direction}` so forward and + * reverse flows stay on separate lanes that match the highway. + */ + types: Map; +} + +function getOrCreateSegment( + segments: Map, + key: string, + init: Omit, +): FeederSegment { + let seg = segments.get(key); + if (!seg) { + seg = { ...init, types: new Map() }; + segments.set(key, seg); + } + return seg; +} + +function mergeFeederTypes( + target: Map, + source: ReadonlyMap, +): void { + for (const [typeKey, info] of source) { + const existing = target.get(typeKey); + if (existing) { + existing.count += info.count; + } else { + target.set(typeKey, { ...info }); + } + } +} + +// Aggregate edge path building + +interface ClassifiedPair { + readonly pairKey: PairKey; + readonly edges: AggregatedVisualEdge[]; + readonly sourceId: ClusterId; + readonly targetId: ClusterId; + readonly hierarchy: HierarchyInfo; +} + +/** + * A highway group: multiple block-level pairs that share + * the same outermost container exit and external target. + * Their edges merge into one highway at the container boundary. + */ +interface HighwayGroupChild { + readonly childId: ClusterId; + readonly childCircle: Circle; + readonly edges: AggregatedVisualEdge[]; +} + +interface HighwayGroup { + readonly highwaySourceId: ClusterId; + readonly highwaySourceCircle: Circle; + readonly highwayTargetId: ClusterId; + readonly highwayTargetCircle: Circle; + /** Children on the source side (inside source container). */ + readonly sourceChildren: HighwayGroupChild[]; + /** Children on the target side (inside target container). */ + readonly targetChildren: HighwayGroupChild[]; +} + +// Main entry point + +export interface EdgeGeometryContext { + readonly clusterTree: ClusterTree; + readonly cutIndex: CutIndex; + /** Visible solid bubbles a highway should route around (excludes its own ends). */ + readonly obstacles: readonly { + readonly id: ClusterId; + readonly circle: Circle; + }[]; +} + +/** Keep a routed highway this far (x obstacle radius) outside the bubble. */ +const ROUTE_CLEARANCE_MUL = 1.15; +/** Ignore obstacles this close (fraction of chord) to either endpoint. */ +const ROUTE_END_MARGIN = 0.08; + +// Bezier segment output (for BezierSDFLayer) + +/** + * Compute raw cubic Bezier curves between consecutive waypoints. + * Returns one CubicCurve per segment (no tessellation). + */ +function computeRawCurves( + source: Waypoint, + target: Waypoint, + crossings: readonly ContainerCrossing[], + config: VizConfig, + bow = true, +): CubicCurve[] { + const waypoints: Waypoint[] = [source]; + + for (let ci = 0; ci < crossings.length; ci++) { + const crossing = crossings[ci]!; + const nextX = + ci < crossings.length - 1 ? crossings[ci + 1]!.circle.x : target.x; + const nextY = + ci < crossings.length - 1 ? crossings[ci + 1]!.circle.y : target.y; + + waypoints.push( + containerBoundaryWaypoint( + crossing.circle, + nextX, + nextY, + config.portPaddingWorld, + ), + ); + } + + waypoints.push(target); + + // Bow only a single straight segment (for parallel-edge separation); routed + // or boundary-crossing paths already get their shape from their waypoints, so + // bowing each segment there just makes them wiggle. + const useBow = bow && waypoints.length === 2; + + const curves: CubicCurve[] = []; + for (let wi = 0; wi < waypoints.length - 1; wi++) { + const from = waypoints[wi]!; + const to = waypoints[wi + 1]!; + const segLen = Math.hypot(to.x - from.x, to.y - from.y); + const tension = config.portTension * segLen; + curves.push(cubicBetweenWaypoints(from, to, tension, useBow)); + } + + return curves; +} + +/** + * Offset a cubic curve perpendicular to its chord (p0->p3). + * This is an approximation; the exact parallel of a cubic Bezier + * is not itself a cubic Bezier. Accurate for small offsets. + */ +function offsetCurve(curve: CubicCurve, offset: number): CubicCurve { + if (offset === 0) { + return curve; + } + const dx = curve.p3[0] - curve.p0[0]; + const dy = curve.p3[1] - curve.p0[1]; + const len = Math.hypot(dx, dy) || 1; + const nx = (-dy / len) * offset; + const ny = (dx / len) * offset; + + return { + p0: [curve.p0[0] + nx, curve.p0[1] + ny], + p1: [curve.p1[0] + nx, curve.p1[1] + ny], + p2: [curve.p2[0] + nx, curve.p2[1] + ny], + p3: [curve.p3[0] + nx, curve.p3[1] + ny], + }; +} + +/** + * Offset a chain of cubics (a routed polyline-of-beziers) by `offset`. The key + * difference from offsetting each segment independently: at a shared waypoint + * the two adjacent segments offset their common endpoint along the same + * (bisector) normal, so a routed lane stays continuous instead of zig-zagging + * sideways at every join. The control handles use each segment's own normal. + */ +function offsetPolyBezier( + curves: readonly CubicCurve[], + offset: number, +): CubicCurve[] { + if (offset === 0) { + return curves.slice(); + } + + const normals = curves.map((curve) => { + const dx = curve.p3[0] - curve.p0[0]; + const dy = curve.p3[1] - curve.p0[1]; + const len = Math.hypot(dx, dy) || 1; + return [-dy / len, dx / len] as const; + }); + + const bisector = ( + n1: readonly [number, number], + n2: readonly [number, number], + ): readonly [number, number] => { + const sumX = n1[0] + n2[0]; + const sumY = n1[1] + n2[1]; + const len = Math.hypot(sumX, sumY) || 1; + return [sumX / len, sumY / len]; + }; + + return curves.map((curve, idx) => { + const own = normals[idx]!; + const start = idx > 0 ? bisector(normals[idx - 1]!, own) : own; + const end = + idx < curves.length - 1 ? bisector(own, normals[idx + 1]!) : own; + return { + p0: [curve.p0[0] + start[0] * offset, curve.p0[1] + start[1] * offset], + p1: [curve.p1[0] + own[0] * offset, curve.p1[1] + own[1] * offset], + p2: [curve.p2[0] + own[0] * offset, curve.p2[1] + own[1] * offset], + p3: [curve.p3[0] + end[0] * offset, curve.p3[1] + end[1] * offset], + }; + }); +} + +function formatCount(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}k`; + } + return String(count); +} + +/** Point on a cubic Bezier at parameter t. */ +function cubicPoint(curve: CubicCurve, t: number): readonly [number, number] { + const u = 1 - t; + const w0 = u * u * u; + const w1 = 3 * u * u * t; + const w2 = 3 * u * t * t; + const w3 = t * t * t; + return [ + w0 * curve.p0[0] + w1 * curve.p1[0] + w2 * curve.p2[0] + w3 * curve.p3[0], + w0 * curve.p0[1] + w1 * curve.p1[1] + w2 * curve.p2[1] + w3 * curve.p3[1], + ]; +} + +/** + * Tangent direction of a cubic at t, as a TextLayer angle in degrees. + * Negated because OrthographicView renders y-down (flipY), so a world-space + * counterclockwise angle is mirrored on screen. Kept in [-90, 90] so the text + * never reads upside down. + */ +function cubicTangentAngle(curve: CubicCurve, t: number): number { + const u = 1 - t; + const dx = + 3 * u * u * (curve.p1[0] - curve.p0[0]) + + 6 * u * t * (curve.p2[0] - curve.p1[0]) + + 3 * t * t * (curve.p3[0] - curve.p2[0]); + const dy = + 3 * u * u * (curve.p1[1] - curve.p0[1]) + + 6 * u * t * (curve.p2[1] - curve.p1[1]) + + 3 * t * t * (curve.p3[1] - curve.p2[1]); + let deg = (-Math.atan2(dy, dx) * 180) / Math.PI; + if (deg > 90) { + deg -= 180; + } else if (deg < -90) { + deg += 180; + } + return deg; +} + +/** World-space tangent angle of a cubic at t, used for geometric marks such as arrowheads. */ +function cubicTangentRadians(curve: CubicCurve, t: number): number { + const u = 1 - t; + const dx = + 3 * u * u * (curve.p1[0] - curve.p0[0]) + + 6 * u * t * (curve.p2[0] - curve.p1[0]) + + 3 * t * t * (curve.p3[0] - curve.p2[0]); + const dy = + 3 * u * u * (curve.p1[1] - curve.p0[1]) + + 6 * u * t * (curve.p2[1] - curve.p1[1]) + + 3 * t * t * (curve.p3[1] - curve.p2[1]); + return Math.atan2(dy, dx); +} + +function arrowSizeForLane(widthWorld: number): number { + return Math.max(widthWorld * 1.15, 1.6); +} + +function labelSizeForLane( + widthWorld: number, + chord: number, + text: string, +): number { + const heightBudget = widthWorld + 4; + const heightFit = heightBudget / (1 + EDGE_LABEL_VERTICAL_PADDING_EM); + const widthFit = + (chord * EDGE_LABEL_MAX_CHORD_FRACTION) / + Math.max( + 1, + text.length * EDGE_LABEL_TEXT_WIDTH_EM + EDGE_LABEL_HORIZONTAL_PADDING_EM, + ); + + return Math.max(1.4, Math.min(heightFit, widthFit)); +} + +/** + * Emit one lane per (type, direction) as offset parallel curves, and one label + * per lane riding it: at the lane midpoint, rotated to the curve tangent, sized + * to the lane's width. The lanes carry typeLabel + count so each colored lane + * is annotated with its own type and flow, not a single dominant summary. + */ +function emitCurveLanes( + out: BezierSegmentSink, + curves: readonly CubicCurve[], + lanes: readonly { + readonly color: Color; + readonly widthWorld: number; + readonly typeLabel: string; + readonly count: number; + readonly direction?: LaneDirection; + /** + * The aggregate lane's stable per-commit id (its index in the edge frame's + * visual-edge list). Carried as the segment `id` so a clicked highway + * segment resolves back to the lane (and thus its link set). Undefined for a + * lane with no single aggregate identity (a merged highway uses the group's + * representative id, passed by the caller). + */ + readonly laneId?: number; + }[], + labelsOut: RenderEdgeLabel[], + arrowsOut: RenderEdgeArrow[], + clipStart?: ClipCircle, + clipEnd?: ClipCircle, +): void { + const gap = LANE_GAP_WORLD; + const midIdx = Math.floor(curves.length / 2); + + // Pack lanes snug and centered on the chord in common/world units. The render layer projects + // widths and offsets with the camera, so zoom does not require worker-side Bezier regeneration. + let bundleWidth = -gap; + for (const info of lanes) { + bundleWidth += info.widthWorld + gap; + } + let cursor = -bundleWidth / 2; + + for (let lane = 0; lane < lanes.length; lane++) { + const info = lanes[lane]!; + const width = info.widthWorld; + const laneOffset = cursor + width / 2; + cursor += width + gap; + + // Offset the whole chain at once (bisector normals at the joins) so a routed + // multi-segment lane stays continuous instead of zig-zagging at waypoints. + const laneCurves = + laneOffset === 0 ? curves : offsetPolyBezier(curves, laneOffset); + let midCurve: CubicCurve | undefined; + const lastCi = laneCurves.length - 1; + for (let ci = 0; ci < laneCurves.length; ci++) { + const curve = laneCurves[ci]!; + // Only the path's first/last segments touch a bubble (the ends); the + // interior routes between them, so it gets no clip. The lane's id rides + // every segment so a clicked highway resolves back to its links. + out.push( + curve, + info.color, + width, + ci === 0 ? clipStart : undefined, + ci === lastCi ? clipEnd : undefined, + info.laneId, + ); + if (ci === midIdx) { + midCurve = curve; + } + } + + if (midCurve && info.count > 0) { + const chord = Math.hypot( + midCurve.p3[0] - midCurve.p0[0], + midCurve.p3[1] - midCurve.p0[1], + ); + const [mx, my] = cubicPoint(midCurve, 0.5); + const text = `${info.typeLabel} ${formatCount(info.count)}`; + labelsOut.push({ + x: mx, + y: my, + text, + angle: cubicTangentAngle(midCurve, 0.5), + // World-sized to stay in the same coordinate system as the lane it annotates. Fitted + // against both lane thickness and the visible chord so long labels don't become banners. + size: labelSizeForLane(width, chord, text), + chord, + }); + if (info.direction === "forward" || info.direction === "reverse") { + const t = info.direction === "forward" ? 0.68 : 0.32; + const [x, y] = cubicPoint(midCurve, t); + arrowsOut.push({ + kind: "lane", + x, + y, + angle: + cubicTangentRadians(midCurve, t) + + (info.direction === "forward" ? 0 : Math.PI), + size: arrowSizeForLane(width), + color: info.color, + chord, + }); + } + } + } +} + +/** Emit feeder paths recursively through the container hierarchy as Bezier + * segments. Every segment carries `laneId` (the highway group's representative + * aggregate lane id) so a clicked feeder resolves to the same link set as its + * highway. */ +function emitRecursiveBezierFeeders( + out: BezierSegmentSink, + arrowsOut: RenderEdgeArrow[], + children: readonly HighwayGroupChild[], + outermostWp: Waypoint, + outermostContainerId: ClusterId, + side: "source" | "target", + clusterTree: ClusterTree, + containerIds: ReadonlySet, + config: VizConfig, + laneId: number, +): void { + const segments = new Map(); + + for (const child of children) { + const childTypes = new Map(); + for (const edge of child.edges) { + const typeKey = (edge.typeSetIdx as number | undefined) ?? -1; + const direction = highwayDirection(edge, child.childId, side); + const key = `${typeKey}:${direction}`; + const existing = childTypes.get(key); + if (existing) { + existing.count += edge.count; + } else { + childTypes.set(key, { + count: edge.count, + color: edge.color, + typeLabel: edge.typeLabel, + direction, + }); + } + } + + let currentId = child.childId; + let currentCircle = child.childCircle; + let node = clusterTree.get(currentId); + + while (node?.parent) { + const parentId = node.parent.id; + if (parentId === outermostContainerId) { + const key = `${currentId}:${outermostContainerId}`; + const seg = getOrCreateSegment(segments, key, { + sourceId: currentId, + sourceCircle: currentCircle, + targetWp: outermostWp, + targetId: outermostContainerId, + }); + mergeFeederTypes(seg.types, childTypes); + break; + } + if (containerIds.has(parentId)) { + const parentCluster = clusterTree.get(parentId); + if (!parentCluster) { + break; + } + const parentPortWp = containerBoundaryWaypoint( + parentCluster.circle, + outermostWp.x, + outermostWp.y, + config.portPaddingWorld, + ); + const key = `${currentId}:${parentId}`; + const seg = getOrCreateSegment(segments, key, { + sourceId: currentId, + sourceCircle: currentCircle, + targetWp: parentPortWp, + targetId: parentId, + }); + mergeFeederTypes(seg.types, childTypes); + currentId = parentId; + currentCircle = parentCluster.circle; + node = parentCluster; + } else { + node = node.parent; + } + } + } + + const gap = LANE_GAP_WORLD; + const childIds = new Set(children.map((child) => child.childId)); + + for (const seg of segments.values()) { + // Where the hop leaves its source circle. The original participant leaves + // toward its own first boundary (`targetWp`). A pass-through container is + // entered at its boundary toward the outermost port (the exact point the + // incoming hop targeted) so consecutive hops share an endpoint. Re-projecting + // a pass-through toward its next hop instead made hops disagree by a few + // position-dependent degrees at every nested intermediate (the feeder kink at + // depth >= 2 intermediate containers; a single intermediate happens to align). + const aimToward = childIds.has(seg.sourceId) ? seg.targetWp : outermostWp; + const sourceAngle = Math.atan2( + aimToward.y - seg.sourceCircle.y, + aimToward.x - seg.sourceCircle.x, + ); + const feederSource: Waypoint = { + x: seg.sourceCircle.x + seg.sourceCircle.radius * Math.cos(sourceAngle), + y: seg.sourceCircle.y + seg.sourceCircle.radius * Math.sin(sourceAngle), + angle: sourceAngle, + }; + const inwardAngle = Math.atan2( + seg.sourceCircle.y - seg.targetWp.y, + seg.sourceCircle.x - seg.targetWp.x, + ); + const feederEnd: Waypoint = { + x: seg.targetWp.x, + y: seg.targetWp.y, + angle: inwardAngle, + }; + + const segLen = Math.hypot( + feederEnd.x - feederSource.x, + feederEnd.y - feederSource.y, + ); + const tension = config.portTension * segLen; + const baseCurve = cubicBetweenWaypoints(feederSource, feederEnd, tension); + + // Clip every hop flush at both its container walls: leave the source's outer + // wall (erase inside the source) and reach the target container's inner wall + // (erase outside the target). Applied at every nesting level, so the feeder + // is flush at intermediate container walls too, where two hops' round caps + // used to overlap into a blob poking through the (translucent) wall. + const targetCircle = clusterTree.get(seg.targetId)?.circle; + const clipStart = clipInside(seg.sourceCircle); + const clipEnd = targetCircle ? clipOutside(targetCircle) : undefined; + + const types = [...seg.types.values()]; + let bundleWidth = -gap; + for (const info of types) { + bundleWidth += widthForCount(info.count) + gap; + } + let cursor = -bundleWidth / 2; + + for (const info of types) { + const laneWidth = widthForCount(info.count); + const laneOffset = cursor + laneWidth / 2; + cursor += laneWidth + gap; + const curve = + laneOffset === 0 ? baseCurve : offsetCurve(baseCurve, laneOffset); + + out.push(curve, info.color, laneWidth, clipStart, clipEnd, laneId); + if (info.direction === "forward" || info.direction === "reverse") { + const t = 0.58; + const [x, y] = cubicPoint(curve, t); + const forwardAlongCurve = + (side === "source" && info.direction === "forward") || + (side === "target" && info.direction === "reverse"); + arrowsOut.push({ + kind: "lane", + x, + y, + angle: + cubicTangentRadians(curve, t) + (forwardAlongCurve ? 0 : Math.PI), + size: arrowSizeForLane(laneWidth), + color: info.color, + chord: segLen, + }); + } + } + } +} + +/** + * Highway-level endpoints of a base pair: the outermost rendered containers the + * edge actually travels between (the leaf endpoints if neither is nested in an + * open container). Ports are computed at this level so a cluster's port toward a + * neighbor subtree stays put whether that subtree's container is open or closed. + */ +export function highwayEndpoints( + sourceId: ClusterId, + targetId: ClusterId, + clusterTree: ClusterTree, + containerIds: ReadonlySet, +): { readonly hwSourceId: ClusterId; readonly hwTargetId: ClusterId } { + const { sourceContainers, targetContainers } = analyzeHierarchy( + sourceId, + targetId, + clusterTree, + containerIds, + ); + return { + hwSourceId: + sourceContainers.length > 0 + ? sourceContainers[sourceContainers.length - 1]!.containerId + : sourceId, + hwTargetId: + targetContainers.length > 0 + ? targetContainers[targetContainers.length - 1]!.containerId + : targetId, + }; +} + +/** The port on each of two clusters for their connecting highway, by id. */ +export function portsFor( + portPairs: ReadonlyMap< + string, + { readonly source: Port; readonly target: Port } + >, + aId: ClusterId, + bId: ClusterId, +): { readonly a: Port; readonly b: Port } | undefined { + const pair = portPairs.get(makePairKey(aId, bId).key); + if (!pair) { + return undefined; + } + // computeAllPorts orders source/target by id; map back to the requested ids. + return aId < bId + ? { a: pair.source, b: pair.target } + : { a: pair.target, b: pair.source }; +} + +/** The worst obstacle intrusion on one polyline segment, if any. */ +interface SegmentHit { + readonly segIndex: number; + /** Foot-of-perpendicular position along the segment. */ + readonly footX: number; + readonly footY: number; + /** Unit normal of the segment (left side). */ + readonly nx: number; + readonly ny: number; + /** Signed perpendicular offset of the obstacle centre from the segment. */ + readonly perp: number; + /** Clearance the waypoint must reach to clear the obstacle. */ + readonly clear: number; + /** How far inside the clearance the obstacle reaches (>0 means it clips). */ + readonly intrusion: number; +} + +/** + * The single worst (deepest-clipping) obstacle across every segment of the + * current polyline `path`, or null if nothing clips. Endpoint clusters and + * obstacles hugging a segment's ends are excluded. + */ +function worstObstacleOnPath( + path: readonly Waypoint[], + exempt: ReadonlySet, + obstacles: readonly { readonly id: ClusterId; readonly circle: Circle }[], +): SegmentHit | null { + let worst: SegmentHit | null = null; + + for (let seg = 0; seg < path.length - 1; seg++) { + const a = path[seg]!; + const b = path[seg + 1]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + const chord = Math.hypot(dx, dy); + if (chord < 1e-6) { + continue; + } + const ux = dx / chord; + const uy = dy / chord; + const nx = -uy; + const ny = ux; + + for (const obstacle of obstacles) { + if (exempt.has(obstacle.id)) { + continue; + } + const ox = obstacle.circle.x - a.x; + const oy = obstacle.circle.y - a.y; + const param = (ox * ux + oy * uy) / chord; + if (param <= ROUTE_END_MARGIN || param >= 1 - ROUTE_END_MARGIN) { + continue; + } + const perp = ox * nx + oy * ny; + const dist = Math.abs(perp); + // Only detour when the segment actually enters the bubble, merely grazing + // its clearance margin shouldn't spawn a waypoint (that zig-zags the path + // through a dense field of bubbles). The waypoint is still placed out at + // the clearance ring, so a real detour keeps its gap. + if (dist >= obstacle.circle.radius) { + continue; + } + const clear = obstacle.circle.radius * ROUTE_CLEARANCE_MUL; + const intrusion = clear - dist; + if (worst === null || intrusion > worst.intrusion) { + worst = { + segIndex: seg, + footX: a.x + ux * (param * chord), + footY: a.y + uy * (param * chord), + nx, + ny, + perp, + clear, + intrusion, + }; + } + } + } + + return worst; +} + +/** Cap routing passes so a pathological cluster of obstacles can't loop. */ +const ROUTE_MAX_PASSES = 8; +/** A point counts as inside a bubble within this multiple of its radius. */ +const ROUTE_CONTAIN_TOLERANCE = 1.02; + +/** Is `pt` inside (or essentially on the edge of) the obstacle circle? */ +function containsPoint( + circle: Circle, + pt: { readonly x: number; readonly y: number }, +): boolean { + return ( + Math.hypot(circle.x - pt.x, circle.y - pt.y) <= + circle.radius * ROUTE_CONTAIN_TOLERANCE + ); +} + +/** + * B1: a highway from `source` to `target` that bends around every intervening + * bubble (one waypoint per obstacle, on the shorter side), or the straight cubic + * if nothing is in the way. Multi-pass: after inserting a detour we re-test the + * whole polyline, so a waypoint that newly clips another bubble is itself routed + * around, the path is clear of all (circle) obstacles when we stop. A bubble + * that encloses an endpoint is exempt: the edge has to enter/leave it, so an + * endpoint's own (highway-level) container and every ancestor enclosing it + * (opened clusters included) are skipped, tested purely geometrically. Curves + * bow gently off the polyline, so segment clearance closely approximates curve + * clearance; residual clipping is only possible after `ROUTE_MAX_PASSES` in a + * densely packed field. + */ +function routeAround( + source: Waypoint, + target: Waypoint, + sourceId: ClusterId, + targetId: ClusterId, + obstacles: readonly { readonly id: ClusterId; readonly circle: Circle }[], + config: VizConfig, +): CubicCurve[] { + if (obstacles.length === 0) { + return computeRawCurves(source, target, [], config); + } + + // A bubble is not an obstacle for an edge whose endpoint lives inside it (its + // own container and every enclosing ancestor, opened clusters included). + const exempt = new Set([sourceId, targetId]); + for (const obstacle of obstacles) { + if ( + containsPoint(obstacle.circle, source) || + containsPoint(obstacle.circle, target) + ) { + exempt.add(obstacle.id); + } + } + + // Build the avoidance polyline by repeatedly routing around the worst clip. + const path: Waypoint[] = [source, target]; + for (let pass = 0; pass < ROUTE_MAX_PASSES; pass++) { + const hit = worstObstacleOnPath(path, exempt, obstacles); + if (!hit) { + break; + } + // Waypoint beside the obstacle, on the side opposite its centre (shorter + // detour), just past its clearance ring. + const side = hit.perp >= 0 ? -1 : 1; + const wpPerp = hit.perp + side * hit.clear; + path.splice(hit.segIndex + 1, 0, { + x: hit.footX + hit.nx * wpPerp, + y: hit.footY + hit.ny * wpPerp, + angle: 0, + }); + } + + // Pull the polyline taut: drop any waypoint whose removal doesn't re-introduce + // a clip. The greedy multi-pass can leave alternating-side waypoints that + // zig-zag the centreline; this keeps only the load-bearing detours. + let removed = true; + while (removed && path.length > 2) { + removed = false; + for (let idx = 1; idx < path.length - 1; idx++) { + const shortcut = [path[idx - 1]!, path[idx + 1]!]; + if (!worstObstacleOnPath(shortcut, exempt, obstacles)) { + path.splice(idx, 1); + removed = true; + break; + } + } + } + + if (path.length === 2) { + return computeRawCurves(source, target, [], config); + } + + // Build smooth C1 cubics through the waypoints. The through-tangent at an + // interior point runs along (next - prev) (Catmull-Rom). Crucial detail: + // `cubicBetweenWaypoints` places a segment's end handle at p2 = p3 + + // tension*dir(angle), so the end angle must point backward along the tangent, + // otherwise p2 lands beyond p3 (further from p0 than p3) and the cubic + // overshoots and loops back on itself at every waypoint. The start handle + // points forward; the path endpoints keep their port-normal angles. + const tangentAt = (idx: number): number => { + if (idx === 0) { + return source.angle; + } + if (idx === path.length - 1) { + return target.angle; + } + const prev = path[idx - 1]!; + const next = path[idx + 1]!; + return Math.atan2(next.y - prev.y, next.x - prev.x); + }; + + const curves: CubicCurve[] = []; + for (let idx = 0; idx < path.length - 1; idx++) { + const a = path[idx]!; + const b = path[idx + 1]!; + const fromAngle = tangentAt(idx); + const toAngle = + idx + 1 === path.length - 1 ? target.angle : tangentAt(idx + 1) + Math.PI; + const segLen = Math.hypot(b.x - a.x, b.y - a.y); + const tension = config.portTension * segLen; + curves.push( + cubicBetweenWaypoints( + { x: a.x, y: a.y, angle: fromAngle }, + { x: b.x, y: b.y, angle: toAngle }, + tension, + false, + ), + ); + } + return curves; +} + +/** + * Build Bezier segments for the BezierSDFLayer, plus label/arrow marks for each directed drawn + * lane. Nested pairs share their merged container highway's lanes instead of scattering numbers + * per base pair. + * + * Segments are written into the caller-owned `out` sink (flat typed arrays) + * rather than allocating per-segment objects. The caller is responsible for + * calling `out.reset()` before invoking and `out.snapshot()` afterwards. + */ +export function buildBezierSegments( + frame: EdgeFrame, + portPairs: ReadonlyMap< + string, + { readonly source: Port; readonly target: Port } + >, + ctx: EdgeGeometryContext, + config: VizConfig, + out: BezierSegmentSink, + labelsOut: RenderEdgeLabel[], + arrowsOut: RenderEdgeArrow[], +): void { + // Classify aggregate edges into pairs. + const byPair = new Map(); + for (const edge of frame.visualEdges) { + if (edge.kind !== "aggregate") { + continue; + } + let list = byPair.get(edge.pairKey); + if (!list) { + list = []; + byPair.set(edge.pairKey, list); + } + list.push(edge); + } + + const classified: ClassifiedPair[] = []; + for (const [pairKey, edges] of byPair) { + const sourceId = edges[0]!.source.id; + const targetId = edges[0]!.target.id; + const hierarchy = analyzeHierarchy( + sourceId, + targetId, + ctx.clusterTree, + ctx.cutIndex.containerIds, + ); + classified.push({ pairKey, edges, sourceId, targetId, hierarchy }); + } + + // Direct pairs: highway curves through the pair's (container-level) ports. + for (const pair of classified) { + if ( + pair.hierarchy.sourceContainers.length > 0 || + pair.hierarchy.targetContainers.length > 0 + ) { + continue; + } + const ports = portsFor(portPairs, pair.sourceId, pair.targetId); + if (!ports) { + continue; + } + const curves = routeAround( + ports.a, + ports.b, + pair.sourceId, + pair.targetId, + ctx.obstacles, + config, + ); + const directSrc = ctx.clusterTree.get(pair.sourceId)?.circle; + const directTgt = ctx.clusterTree.get(pair.targetId)?.circle; + emitCurveLanes( + out, + curves, + pair.edges, + labelsOut, + arrowsOut, + directSrc ? clipInside(directSrc) : undefined, + directTgt ? clipInside(directTgt) : undefined, + ); + } + + // Hierarchical pairs: merged highways + feeders. + const highwayGroups = new Map(); + + for (const pair of classified) { + const { sourceContainers, targetContainers } = pair.hierarchy; + if (sourceContainers.length === 0 && targetContainers.length === 0) { + continue; + } + + const outermostSource = + sourceContainers.length > 0 + ? sourceContainers[sourceContainers.length - 1]! + : undefined; + const outermostTarget = + targetContainers.length > 0 + ? targetContainers[targetContainers.length - 1]! + : undefined; + + const hwSourceId = outermostSource?.containerId ?? pair.sourceId; + const hwTargetId = outermostTarget?.containerId ?? pair.targetId; + const groupKey = `${hwSourceId}\x1f${hwTargetId}`; + + let group = highwayGroups.get(groupKey); + if (!group) { + const srcCircle = outermostSource?.circle ?? + ctx.clusterTree.get(pair.sourceId)?.circle ?? { x: 0, y: 0, radius: 0 }; + const tgtCircle = outermostTarget?.circle ?? + ctx.clusterTree.get(pair.targetId)?.circle ?? { x: 0, y: 0, radius: 0 }; + + group = { + highwaySourceId: hwSourceId, + highwaySourceCircle: srcCircle, + highwayTargetId: hwTargetId, + highwayTargetCircle: tgtCircle, + sourceChildren: [], + targetChildren: [], + }; + highwayGroups.set(groupKey, group); + } + + if (outermostSource) { + const childCluster = ctx.clusterTree.get(pair.sourceId); + if (childCluster) { + group.sourceChildren.push({ + childId: pair.sourceId, + childCircle: childCluster.circle, + edges: pair.edges, + }); + } + } + if (outermostTarget) { + const childCluster = ctx.clusterTree.get(pair.targetId); + if (childCluster) { + group.targetChildren.push({ + childId: pair.targetId, + childCircle: childCluster.circle, + edges: pair.edges, + }); + } + } + } + + for (const group of highwayGroups.values()) { + // Each pair contributes its edges once. When both endpoints are + // nested, the pair appears in both sourceChildren and targetChildren + // with the same edges, so merging both sides doubles every lane. + const usingSource = group.sourceChildren.length > 0; + const mergeChildren = usingSource + ? group.sourceChildren + : group.targetChildren; + const merged = mergeLanes(mergeChildren, usingSource ? "source" : "target"); + if (merged.length === 0) { + continue; + } + + // A merged highway collapses several aggregate edges (per type+direction + // across children) into one ribbon, so no single lane id applies. Carry the + // group's representative aggregate lane id on every highway + feeder + // segment, so a clicked merged highway still resolves to a real lane's link + // set. Lanes carry it via a tagged copy; feeders take it as an argument. + const groupLaneId = mergeChildren[0]?.edges[0]?.laneId ?? BEZIER_NO_LINK; + const mergedLanes = merged.map((lane) => ({ + ...lane, + laneId: groupLaneId, + })); + + // Route through the stable container-level ports so the highway attaches at + // the same point whether the container is open or closed; fall back to a raw + // boundary waypoint if a port is missing, so edges always render. + const hp = portsFor( + portPairs, + group.highwaySourceId, + group.highwayTargetId, + ); + const sourceWp: Waypoint = + hp?.a ?? + containerBoundaryWaypoint( + group.highwaySourceCircle, + group.highwayTargetCircle.x, + group.highwayTargetCircle.y, + config.portPaddingWorld, + ); + const targetWp: Waypoint = + hp?.b ?? + containerBoundaryWaypoint( + group.highwayTargetCircle, + group.highwaySourceCircle.x, + group.highwaySourceCircle.y, + config.portPaddingWorld, + ); + + const curves = routeAround( + sourceWp, + targetWp, + group.highwaySourceId, + group.highwayTargetId, + ctx.obstacles, + config, + ); + emitCurveLanes( + out, + curves, + mergedLanes, + labelsOut, + arrowsOut, + clipInside(group.highwaySourceCircle), + clipInside(group.highwayTargetCircle), + ); + + // Feeders as Bezier segments. + emitRecursiveBezierFeeders( + out, + arrowsOut, + group.sourceChildren, + sourceWp, + group.highwaySourceId, + "source", + ctx.clusterTree, + ctx.cutIndex.containerIds, + config, + groupLaneId, + ); + emitRecursiveBezierFeeders( + out, + arrowsOut, + group.targetChildren, + targetWp, + group.highwayTargetId, + "target", + ctx.clusterTree, + ctx.cutIndex.containerIds, + config, + groupLaneId, + ); + } + + // Individual entity edges and entity fan-out feeders are not emitted here. + // They depend on per-entity positions that stream through the position + // SharedArrayBuffer, and would otherwise force this whole O(entities * degree) + // pass to re-run on every force tick. The main thread composes them as straight + // LineLayers from the same shared buffer the dots read (see RenderEntityLayer), + // so dots and their edges share one update channel and cannot tear. +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/entity-fanout-exit.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/entity-fanout-exit.test.ts new file mode 100644 index 00000000000..2a2bf39e7c2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/entity-fanout-exit.test.ts @@ -0,0 +1,50 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { containerBoundaryWaypoint } from "./edge-geometry"; + +/** + * Locks the depth-≥2 fan-out geometry. The FEEDER leaves an entity bucket toward + * its nearest enclosing container's boundary (in the direction of the outermost + * port), then hops outward. The fan-out exit (where the dots' feeders converge + * on the bucket rim) MUST aim at that same first waypoint. The old code aimed + * straight at the outermost port `hp.a`; with an intermediate container between + * the leaf and the outermost, that diverges by a position-dependent few degrees + * — the "4 vs 5 o'clock" drift that only showed up at depth ≥ 2. + */ +const outermostPort = { x: 100, y: 0 }; // hp.a, on the outermost container's rim +const intermediate = { x: -30, y: -40, radius: 30 }; // container between leaf + outer +const leaf = { x: -45, y: -50, radius: 5 }; // off-centre inside the intermediate + +function angleFromLeaf(target: { x: number; y: number }): number { + return Math.atan2(target.y - leaf.y, target.x - leaf.x); +} + +describe("entity fan-out exit (depth ≥ 2)", () => { + // What the feeder actually does (edge-geometry.ts emitRecursiveBezierFeeders): + // leave toward the enclosing container's boundary, aimed at the outermost port. + const feederFirstWaypoint = containerBoundaryWaypoint( + intermediate, + outermostPort.x, + outermostPort.y, + 0, + ); + const feederExitAngle = angleFromLeaf(feederFirstWaypoint); + + it("the fixed exit aims exactly where the feeder leaves the bucket", () => { + // The fix points the exit at the same waypoint the feeder uses. + const fixedExitAngle = angleFromLeaf(feederFirstWaypoint); + expect(Math.abs(fixedExitAngle - feederExitAngle)).toBeLessThan(1e-9); + }); + + it("aiming straight at the outermost port (the old bug) drifts off the feeder", () => { + const oldExitAngle = angleFromLeaf(outermostPort); + const driftDegrees = + (Math.abs(oldExitAngle - feederExitAngle) * 180) / Math.PI; + // eslint-disable-next-line no-console + console.log( + `[fanout-exit] old-vs-feeder drift = ${driftDegrees.toFixed(2)}°`, + ); + expect(driftDegrees).toBeGreaterThan(2); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/feeder-continuity.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/feeder-continuity.test.ts new file mode 100644 index 00000000000..da71c2796c7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/feeder-continuity.test.ts @@ -0,0 +1,170 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { containerBoundaryWaypoint } from "./edge-geometry"; + +/** + * Recursive feeder hop continuity. `emitRecursiveBezierFeeders` draws each hop + * (childOrContainer → next boundary) as its own curve. A hop's END is a + * container boundary aimed at the OUTERMOST port; the next hop's START is + * re-projected onto that container's rim aimed at ITS OWN next target. For the + * feeder to be continuous, hop[i].end must equal hop[i+1].start on the shared + * container rim. This test shows they agree with ONE intermediate container but + * drift apart with TWO — the same "aimed at the wrong reference" class as the + * entity fan-out, surfacing as a few-degree feeder kink at deep nesting. + */ +const outermostPort = { x: 200, y: 0 }; + +function angleAt( + circle: { x: number; y: number }, + pt: { x: number; y: number }, +): number { + return Math.atan2(pt.y - circle.y, pt.x - circle.x); +} + +describe("feeder hop continuity", () => { + it("ONE intermediate container: incoming end == outgoing start (continuous)", () => { + // participant inside `inner`, `inner` directly inside the outermost. + const inner = { x: 20, y: 30, radius: 20 }; + + // Incoming hop (participant → inner): ends at inner's boundary toward outer. + const incomingEnd = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + // Outgoing hop (inner → outermost): since inner's parent IS the outermost, + // its target is the outermost port directly, so its start aims there too. + const outgoingStart = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + const driftDeg = + (Math.abs(angleAt(inner, incomingEnd) - angleAt(inner, outgoingStart)) * + 180) / + Math.PI; + expect(driftDeg).toBeLessThan(1e-9); + }); + + it("TWO intermediate containers: incoming end != outgoing start (the drift)", () => { + const outer = { x: 60, y: 10, radius: 40 }; // intermediate closer to outermost + const inner = { x: 20, y: 30, radius: 20 }; // intermediate around the participant + + // Incoming hop (participant → inner): ends at inner's boundary toward the + // OUTERMOST port. + const incomingEnd = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + // Outgoing hop (inner → outer): target is OUTER's boundary toward outermost; + // the code re-projects the start onto inner's rim aimed at THAT (not the + // outermost). This is the bug. + const outerBoundary = containerBoundaryWaypoint( + outer, + outermostPort.x, + outermostPort.y, + 0, + ); + const outgoingStart = containerBoundaryWaypoint( + inner, + outerBoundary.x, + outerBoundary.y, + 0, + ); + + const driftDeg = + (Math.abs(angleAt(inner, incomingEnd) - angleAt(inner, outgoingStart)) * + 180) / + Math.PI; + // eslint-disable-next-line no-console + console.log( + `[feeder] two-intermediate hop drift = ${driftDeg.toFixed(2)}°`, + ); + expect(driftDeg).toBeGreaterThan(2); + + // The fix: a pass-through container leaves at its boundary toward the + // OUTERMOST port — the same point the incoming hop ended at — so the hops + // share an endpoint again. + const outgoingStartFixed = containerBoundaryWaypoint( + inner, + outermostPort.x, + outermostPort.y, + 0, + ); + const fixedDriftDeg = + (Math.abs( + angleAt(inner, incomingEnd) - angleAt(inner, outgoingStartFixed), + ) * + 180) / + Math.PI; + expect(fixedDriftDeg).toBeLessThan(1e-9); + }); + + it("THREE intermediates: the fix is continuous at EVERY junction (recursive, any depth)", () => { + // Chain inner→outer: participant → c3 → c2 → c1 → outermost. The feeder hops + // outward, so each container's "next hop" is the next-OUTER container (and the + // outermost intermediate hops straight to the port). + const containers = [ + { x: 35, y: 55, radius: 18 }, // c3 (around the participant — innermost) + { x: 60, y: 40, radius: 35 }, // c2 + { x: 120, y: 20, radius: 60 }, // c1 (closest to outermost) + ]; + + // Each container's crossing is defined ONCE, toward the outermost port. + const crossings = containers.map((container) => + containerBoundaryWaypoint(container, outermostPort.x, outermostPort.y, 0), + ); + + let maxOldDrift = 0; + for (let level = 0; level < containers.length; level++) { + const container = containers[level]!; + const crossing = crossings[level]!; + // FIX: the hop ARRIVING here ends at this container's crossing (toward the + // outermost), and the LEAVING pass-through hop aims at the outermost too — + // the same point. So the junction is continuous at EVERY level, any depth. + const fixedStart = containerBoundaryWaypoint( + container, + outermostPort.x, + outermostPort.y, + 0, + ); + const fixedDrift = + (Math.abs( + angleAt(container, crossing) - angleAt(container, fixedStart), + ) * + 180) / + Math.PI; + expect(fixedDrift).toBeLessThan(1e-9); + + // OLD: the leaving hop was re-projected toward the NEXT crossing. The drift + // is position-dependent (large at one junction, near-zero at another — the + // "sometimes fits" effect), but present somewhere on any 2+-deep chain. + if (level < containers.length - 1) { + const nextCrossing = crossings[level + 1]!; + const oldStart = containerBoundaryWaypoint( + container, + nextCrossing.x, + nextCrossing.y, + 0, + ); + const oldDrift = + (Math.abs( + angleAt(container, crossing) - angleAt(container, oldStart), + ) * + 180) / + Math.PI; + // eslint-disable-next-line no-console + console.log( + `[feeder] level ${level} old drift = ${oldDrift.toFixed(2)}°`, + ); + maxOldDrift = Math.max(maxOldDrift, oldDrift); + } + } + expect(maxOldDrift).toBeGreaterThan(2); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.test.ts new file mode 100644 index 00000000000..8884aafcc2e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.test.ts @@ -0,0 +1,106 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { ClusterId } from "../../ids"; +import { ClusterNode } from "../hierarchy/cluster-tree"; +import { syncWorldPositions } from "./world-positions"; + +import type { ForceNode, LayoutSimulation } from "../layout/force-simulation"; + +/** + * A minimal settled LayoutSimulation whose node local offsets we control, so we + * test the world composition without any solver noise. + */ +function fakeLayout(localOffsets: { id: string; x: number; y: number }[]): { + layout: LayoutSimulation; + nodes: ForceNode[]; +} { + const nodes: ForceNode[] = localOffsets.map((offset) => ({ + id: offset.id, + radius: 1, + x: offset.x, + y: offset.y, + })); + const layout: LayoutSimulation = { + status: "settled", + isSettled: true, + nodes, + buffer: new ArrayBuffer(0), + nodeIds: nodes.map((node) => node.id), + alpha: 0, + tick: () => false, + pause: () => {}, + resume: () => {}, + }; + return { layout, nodes }; +} + +function makeNode(id: string): ClusterNode { + const node = new ClusterNode(ClusterId(id), "community", { + source: "groups", + keys: [], + }); + node.circle.radius = 10; + return node; +} + +describe("syncWorldPositions — nested world composition", () => { + it("composes leaf world = root + container-local + leaf-local at depth 2", () => { + const root = makeNode("cluster:root"); + const container = makeNode("A"); // top-level container + const leaf = makeNode("A:leaf"); // depth-2 leaf inside the container + root.addChild(container); + container.addChild(leaf); + + // The root's layout (keyed by the root) places the container at local + // (100, 0); the container's layout (keyed by the container) places the leaf + // at local (5, 3). A layout is keyed by the PARENT whose children it lays. + const rootLayout = fakeLayout([{ id: "A", x: 100, y: 0 }]); + const containerLayout = fakeLayout([{ id: "A:leaf", x: 5, y: 3 }]); + const layouts = new Map([ + ["cluster:root", rootLayout.layout], + ["A", containerLayout.layout], + ]); + const isCluster = (id: ClusterId): boolean => layouts.has(id); + const layoutFor = (id: ClusterId): LayoutSimulation | undefined => + layouts.get(id); + + syncWorldPositions(root, layoutFor, isCluster); + + expect(container.circle.x).toBe(100); + expect(leaf.circle.x).toBe(105); // 0 (root) + 100 (A) + 5 (leaf) + expect(leaf.circle.y).toBe(3); + }); + + it("carries a deep leaf when the macro moves the container, through a settled intermediate", () => { + const root = makeNode("cluster:root"); + const container = makeNode("A"); + const leaf = makeNode("A:leaf"); + root.addChild(container); + container.addChild(leaf); + + const rootLayout = fakeLayout([{ id: "A", x: 100, y: 0 }]); + const containerLayout = fakeLayout([{ id: "A:leaf", x: 5, y: 3 }]); + const layouts = new Map([ + ["cluster:root", rootLayout.layout], + ["A", containerLayout.layout], + ]); + const isCluster = (id: ClusterId): boolean => layouts.has(id); + const layoutFor = (id: ClusterId): LayoutSimulation | undefined => + layouts.get(id); + + syncWorldPositions(root, layoutFor, isCluster); + expect(leaf.circle.x).toBe(105); + + // The macro re-settles and moves the container by +50 in x — only the ROOT + // layout changes; the container's own (settled) layout is untouched. + rootLayout.nodes[0]!.x = 150; + + syncWorldPositions(root, layoutFor, isCluster); + + // The deep leaf must follow the container, NOT stay at its old world x. + expect(container.circle.x).toBe(150); + expect(leaf.circle.x).toBe(155); // followed: 150 + 5 + expect(leaf.circle.y).toBe(3); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts new file mode 100644 index 00000000000..d16d80faf70 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts @@ -0,0 +1,60 @@ +/** + * Authoritative world-position composition for the opened cluster subtree. + * + * A nested cluster's world position is a pure function of the layout offsets + * down its ancestor chain: `child.world = parent.world + child.localOffset`, + * where `localOffset` is the child's position in the parent's (local-frame) + * layout. The macro layout moves only the top-level clusters; everything deeper + * inherits that movement through this composition. + * + * Crucially this must run even through layouts that have already settled: at + * depth >= 2, an intermediate container's own layout is fine (its children's + * local offsets are stable) but its world position shifted because the macro + * moved it, and a settled layout never ticks to re-publish its children's world + * circles. So instead of relying on each layout to write its own children when + * it happens to tick (which left depth >= 2 leaves, and the ports the entity dots + * chase, frozen at the parent's old position), we recompose the whole opened + * subtree top-down before any positional read. + * + * Top-down order matters: a parent's world circle is updated before we descend, + * so each level reads an already-correct parent. Recursion stops where there is + * no child cluster layout (closed clusters, entity leaves), so the cost is + * bounded by the opened subtree, not the whole tree. + */ +import type { ClusterId } from "../../ids"; +import type { ClusterNode } from "../hierarchy/cluster-tree"; +import type { LayoutSimulation } from "../layout/force-simulation"; + +export function syncWorldPositions( + root: ClusterNode, + layoutFor: (id: ClusterId) => LayoutSimulation | undefined, + isClusterLayout: (id: ClusterId) => boolean, +): void { + const visit = (cluster: ClusterNode): void => { + const layout = layoutFor(cluster.id); + // A "clusters" layout whose node set still matches the children positions + // those children; anything else (entity layout, stale/mismatched layout, a + // closed cluster with no layout) leaves the children as-is and we stop. + if ( + layout && + isClusterLayout(cluster.id) && + cluster.children.length === layout.nodes.length + ) { + const childById = new Map( + cluster.children.map((child) => [child.id, child]), + ); + for (const node of layout.nodes) { + const child = childById.get(node.id as ClusterId); + if (child) { + child.circle.x = cluster.circle.x + (node.x ?? 0); + child.circle.y = cluster.circle.y + (node.y ?? 0); + } + } + } + for (const child of cluster.children) { + visit(child); + } + }; + + visit(root); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts new file mode 100644 index 00000000000..3b009cefa3c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts @@ -0,0 +1,133 @@ +/** + * Build a {@link FeatureSource} for {@link nameClustersByDistinctiveFeatures} over the worker's + * stores. This is the seam where the distinctive-feature namer (a pure module) meets the + * worker's by-value indexes: + * + * - exact `(property = value)` features and raw numeric/date readings come from the + * {@link PropertyStore}, + * - link/target-type features come from the {@link LinkStore}: for each link a member + * participates in, the PRIMARY type of the entity at the other end, resolved to its title + * via the type registry -- the answer to "what does this group link TO". The target's + * SUB-cluster would be a sharper signal, but those clusters don't exist yet at naming time; + * the target's type is the coarse proxy available now. + * + * Feature keys are namespaced strings (`p`/`lt`/`n` + NUL-separated fields) so the namer can + * treat them as opaque while this module decodes them in {@link FeatureSource.describe}. + */ +import { primaryTypeOfSet } from "../entity-style"; + +import type { EntityIdx, TypeIdx } from "../../ids"; +import type { EntityStore } from "../stores/entity-store"; +import type { LinkStore } from "../stores/link-store"; +import type { PropertyStore } from "../stores/property-store"; +import type { TypeRegistry } from "../stores/type-registry"; +import type { TypeSetStore } from "../stores/type-set-store"; +import type { + FeatureDescriptor, + FeatureSource, + NumericDimension, + NumericReading, +} from "./distinctive-cluster-label"; + +export interface ClusterFeatureDeps { + readonly properties: PropertyStore; + readonly links: LinkStore; + readonly entities: EntityStore; + readonly typeSets: TypeSetStore; + readonly types: TypeRegistry; +} + +/** Sorts link parts after property/range parts (which sort by their human title). */ +const LINK_SORT_PREFIX = "\uFFFF"; + +export function createClusterFeatureSource( + deps: ClusterFeatureDeps, +): FeatureSource { + const { properties, links, entities, typeSets, types } = deps; + + /** The primary (most specific) type of the entity an endpoint points at, if known. */ + const targetTypeIdx = (otherIdx: EntityIdx): TypeIdx | undefined => { + const groupIdx = entities.getTypeGroup(otherIdx); + if (groupIdx === -1) { + return undefined; + } + const group = typeSets.getByIdx(groupIdx); + if (!group) { + return undefined; + } + return primaryTypeOfSet(group.directTypeIdxs, types); + }; + + return { + *keysOf(member: EntityIdx): Iterable { + const features = properties.featuresOf(member); + if (features) { + for (const featureIdx of features) { + yield `p\u0000${featureIdx}`; + } + } + for (const link of links.linksForEntity(member)) { + const typeIdx = targetTypeIdx(link.otherIdx); + if (typeIdx === undefined) { + continue; + } + yield `lt\u0000${link.direction}\u0000${typeIdx}`; + } + }, + + *numericsOf(member: EntityIdx): Iterable { + const keys = properties.numericKeysOf(member); + const values = properties.numericValuesOf(member); + if (!keys || !values) { + return; + } + for (let index = 0; index < keys.length; index++) { + yield { dimension: `n\u0000${keys[index]!}`, value: values[index]! }; + } + }, + + describe(key: string): FeatureDescriptor | undefined { + const parts = key.split("\u0000"); + if (parts[0] === "p") { + const info = properties.describe(Number(parts[1])); + if (!info) { + return undefined; + } + return { + group: `prop\u0000${info.baseUrl}`, + text: `${info.title} = ${info.display}`, + sortKey: info.title, + }; + } + if (parts[0] === "lt") { + const direction = parts[1]; + const info = types.get(Number(parts[2]) as TypeIdx); + if (!info) { + return undefined; + } + const arrow = direction === "out" ? "→" : "←"; + return { + group: key, + text: `${arrow} ${info.title}`, + sortKey: `${LINK_SORT_PREFIX}${direction}\u0000${info.title}`, + }; + } + return undefined; + }, + + describeNumeric(dimension: string): NumericDimension | undefined { + const keyIdx = Number(dimension.split("\u0000")[1]); + const baseUrl = properties.numericBaseUrl(keyIdx); + if (baseUrl === undefined) { + return undefined; + } + const title = properties.title(baseUrl); + return { + group: `prop\u0000${baseUrl}`, + title, + kind: properties.numericKind(keyIdx), + sortKey: title, + }; + }, + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-radii.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-radii.test.ts new file mode 100644 index 00000000000..f884adad088 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-radii.test.ts @@ -0,0 +1,43 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { enclosingRadius } from "./cluster-tree"; + +// The top-level radius floor a count-6 leaf (e.g. the old "Company") would get. +const TOP_LEVEL_FLOOR = 15; + +describe("enclosingRadius", () => { + it("is 0 for no children", () => { + expect(enclosingRadius([], 2)).toBe(0); + }); + + it("is the child radius for a single child", () => { + expect(enclosingRadius([10], 2)).toBe(10); + }); + + it("sums the radii (+gap) for two children placed side by side", () => { + expect(enclosingRadius([8, 8], 2)).toBe(18); + expect(enclosingRadius([11, 8], 2)).toBe(21); + }); + + it("ignores child order (uses the two largest)", () => { + expect(enclosingRadius([8, 11], 2)).toBe(enclosingRadius([11, 8], 2)); + }); + + it("regression: two r=8 children need MORE than a count-6 leaf radius (15)", () => { + // The Company family was sized 15 by count but had to hold two r=8 children + // → overlap. The enclosing radius must exceed 15 so the container grows. + expect(enclosingRadius([8, 8], 2)).toBeGreaterThan(TOP_LEVEL_FLOOR); + }); + + it("places 3+ children on a ring (radius = ring + largest child)", () => { + const expected = (5 + 5 + 2) / (2 * Math.sin(Math.PI / 3)) + 5; + expect(enclosingRadius([5, 5, 5], 2)).toBeCloseTo(expected, 6); + }); + + it("grows as more equal children are added", () => { + expect(enclosingRadius([6, 6, 6, 6], 2)).toBeGreaterThan( + enclosingRadius([6, 6], 2), + ); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts new file mode 100644 index 00000000000..7dcbe364e1e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts @@ -0,0 +1,1382 @@ +/* eslint-disable no-param-reassign */ +import { MutableCircle } from "../../geometry"; +import { ClusterId } from "../../ids"; +import { graphColors, hslToRgb } from "../../visual-style"; +import { Column } from "../collections/column"; +import { subclusterByLinks } from "./community"; + +import type { VizConfig } from "../../config"; +import type { Color } from "../../frames"; +import type { ClusterKind, EntityIdx, TypeIdx, TypeSetKey } from "../../ids"; +import type { LinkStore } from "../stores/link-store"; +import type { TypeRegistry } from "../stores/type-registry"; +import type { TypeSetGroup, TypeSetStore } from "../stores/type-set-store"; + +/* eslint-disable no-bitwise */ +function stableHashToAngle(id: string): number { + let hash = 0; + for (let idx = 0; idx < id.length; idx++) { + hash = ((hash << 5) - hash + id.charCodeAt(idx)) | 0; + } + return ((hash >>> 0) / 0xffffffff) * 2 * Math.PI; +} +/* eslint-enable no-bitwise */ + +export class ClusterLabel { + readonly text: string; + readonly primaryType: TypeIdx | null; + readonly coverage: number; + readonly isMixed: boolean; + + constructor( + text?: string, + primaryType?: TypeIdx | null, + coverage?: number, + isMixed?: boolean, + ) { + this.text = text ?? ""; + this.primaryType = primaryType ?? null; + this.coverage = coverage ?? 0; + this.isMixed = isMixed ?? true; + } +} + +export class ClusterMass { + readonly direct = new Map(); + readonly closure = new Map(); + + addGroup(group: TypeSetGroup): void { + for (const typeIdx of group.directTypeIdxs) { + this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + group.count); + } + for (const typeIdx of group.closure.members()) { + this.closure.set(typeIdx, (this.closure.get(typeIdx) ?? 0) + group.count); + } + } + + removeGroup(group: TypeSetGroup, entityCount: number): void { + for (const typeIdx of group.directTypeIdxs) { + const prev = this.direct.get(typeIdx) ?? 0; + this.direct.set(typeIdx, Math.max(0, prev - entityCount)); + } + for (const typeIdx of group.closure.members()) { + const prev = this.closure.get(typeIdx) ?? 0; + this.closure.set(typeIdx, Math.max(0, prev - entityCount)); + } + } + + incrementForGroup(group: TypeSetGroup, delta: number): void { + for (const typeIdx of group.directTypeIdxs) { + this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + delta); + } + for (const typeIdx of group.closure.members()) { + this.closure.set(typeIdx, (this.closure.get(typeIdx) ?? 0) + delta); + } + } + + absorb(other: ClusterMass): void { + for (const [typeIdx, mass] of other.direct) { + this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + mass); + } + for (const [typeIdx, mass] of other.closure) { + this.closure.set(typeIdx, (this.closure.get(typeIdx) ?? 0) + mass); + } + } +} + +interface GroupMembership { + readonly source: "groups"; + readonly keys: TypeSetKey[]; +} + +interface DirectMembership { + readonly source: "direct"; + readonly members: Column; +} + +export type ClusterMembership = GroupMembership | DirectMembership; + +export class ClusterNode { + readonly id: ClusterId; + readonly kind: ClusterKind; + readonly mass: ClusterMass; + readonly children: ClusterNode[]; + readonly membership: ClusterMembership; + count: number; + label: ClusterLabel; + readonly circle: MutableCircle; + + #parent: WeakRef | null = null; + + constructor(id: ClusterId, kind: ClusterKind, membership: ClusterMembership) { + this.id = id; + this.kind = kind; + this.mass = new ClusterMass(); + this.children = []; + this.membership = membership; + this.count = 0; + this.label = new ClusterLabel(); + this.circle = new MutableCircle(); + } + + get parent(): ClusterNode | null { + return this.#parent?.deref() ?? null; + } + + set parent(node: ClusterNode | null) { + this.#parent = node ? new WeakRef(node) : null; + } + + addChild(child: ClusterNode): void { + this.children.push(child); + child.parent = this; + } + + removeChild(child: ClusterNode): void { + const idx = this.children.indexOf(child); + if (idx !== -1) { + this.children.splice(idx, 1); + } + child.parent = null; + } + + clearChildren(): void { + for (const child of this.children) { + child.parent = null; + } + this.children.length = 0; + } + + addGroupMass(group: TypeSetGroup): void { + if (this.membership.source !== "groups") { + return; + } + if (!this.membership.keys.includes(group.key)) { + this.membership.keys.push(group.key); + } + this.count += group.count; + this.mass.addGroup(group); + } + + removeGroupMass(group: TypeSetGroup, entityCount: number): void { + if (this.membership.source !== "groups") { + return; + } + const keyIdx = this.membership.keys.indexOf(group.key); + if (keyIdx !== -1) { + this.membership.keys.splice(keyIdx, 1); + } + this.count -= entityCount; + this.mass.removeGroup(group, entityCount); + } + + incrementGroupMass(group: TypeSetGroup, delta: number): void { + this.count += delta; + this.mass.incrementForGroup(group, delta); + } +} + +export interface IngestDelta { + readonly groupKey: TypeSetKey; + readonly delta: number; + readonly isNewGroup: boolean; + readonly previousCount: number; +} + +function stableSortNodes(nodes: ClusterNode[]): ClusterNode[] { + return [...nodes].sort( + (lhs, rhs) => rhs.count - lhs.count || lhs.id.localeCompare(rhs.id), + ); +} + +function mostSimilarSibling( + target: ClusterNode, + siblings: ClusterNode[], +): ClusterNode | undefined { + let best: ClusterNode | undefined; + let bestOverlap = 0; + + for (const sibling of siblings) { + if (sibling.id === target.id || sibling.circle.isOrigin) { + continue; + } + + let overlap = 0; + for (const [typeIdx, mass] of target.mass.closure) { + const siblingMass = sibling.mass.closure.get(typeIdx); + if (siblingMass !== undefined) { + overlap += Math.min(mass, siblingMass); + } + } + + if (overlap > bestOverlap) { + bestOverlap = overlap; + best = sibling; + } + } + + return best; +} + +function clampChildrenToParent( + children: readonly ClusterNode[], + parent: ClusterNode, + padding: number, +): void { + for (const child of children) { + const dx = child.circle.x - parent.circle.x; + const dy = child.circle.y - parent.circle.y; + const dist = Math.hypot(dx, dy); + const maxDist = parent.circle.radius - child.circle.radius - padding; + + if (maxDist <= 0) { + // Child is too large to fit: center it. + child.circle.x = parent.circle.x; + child.circle.y = parent.circle.y; + } else if (dist > maxDist) { + const scale = maxDist / dist; + child.circle.x = parent.circle.x + dx * scale; + child.circle.y = parent.circle.y + dy * scale; + } + } +} + +function resolveCollisions( + nodes: readonly ClusterNode[], + iterations: number, +): void { + const padding = 4; + + for (let iter = 0; iter < iterations; iter++) { + let anyOverlap = false; + + for (let idx = 0; idx < nodes.length; idx++) { + const a = nodes[idx]!; + for (let jdx = idx + 1; jdx < nodes.length; jdx++) { + const b = nodes[jdx]!; + + if (a.circle.pushApart(b.circle, padding)) { + anyOverlap = true; + } else if ( + Math.hypot(b.circle.x - a.circle.x, b.circle.y - a.circle.y) <= 0.001 + ) { + // Coincident: pushApart can't determine direction. + // Separate along a deterministic angle derived from IDs. + anyOverlap = true; + const angle = stableHashToAngle(`${a.id}${b.id}`); + a.circle.x -= Math.cos(angle) * 2; + a.circle.y -= Math.sin(angle) * 2; + b.circle.x += Math.cos(angle) * 2; + b.circle.y += Math.sin(angle) * 2; + } + } + } + + if (!anyOverlap) { + break; + } + } +} + +/** Count -> leaf radius: area proportional to count (the same sqrt(count)*k mapping at every level). */ +const RADIUS_PER_SQRT_COUNT = 5; +/** Floor so the smallest leaves stay visible. */ +const LEAF_MIN_RADIUS = 8; +/** Larger floor for top-level bubbles, so a singleton type stays clickable. */ +const TOP_LEVEL_MIN_RADIUS = 15; +/** Inter-child gap and rim margin used when sizing a container to fit. */ +const ENCLOSE_GAP = 2; +const ENCLOSE_PADDING = 3; + +/** + * Radius of a circle that can hold children of the given radii without overlap, + * placed on a ring. A ring is always a valid non-overlapping arrangement, so a + * confined force layout can always pack the children within this radius. Tight + * for <= 2 children (the common family case, two circles side by side); a single + * ring for more (conservative; the force layout then packs them tighter). + */ +export function enclosingRadius(radii: readonly number[], gap: number): number { + const count = radii.length; + if (count === 0) { + return 0; + } + if (count === 1) { + return radii[0]!; + } + const sorted = [...radii].sort((left, right) => right - left); + // The two largest children could end up adjacent, size for that worst case. + const widestPair = sorted[0]! + sorted[1]! + gap; + if (count === 2) { + return widestPair; + } + const ringRadius = widestPair / (2 * Math.sin(Math.PI / count)); + return ringRadius + sorted[0]!; +} + +function documentFrequency( + clusters: readonly ClusterNode[], + massKey: "direct" | "closure", +): Map { + const df = new Map(); + + for (const cluster of clusters) { + for (const typeIdx of cluster.mass[massKey].keys()) { + df.set(typeIdx, (df.get(typeIdx) ?? 0) + 1); + } + } + + return df; +} + +function bestCandidate( + cluster: ClusterNode, + mass: Map, + df: Map, + minCoverage: number, + totalClusters: number, + types: TypeRegistry, +): { typeIdx: TypeIdx; coverage: number; score: number } | undefined { + let best: { typeIdx: TypeIdx; coverage: number; score: number } | undefined; + + for (const [typeIdx, count] of mass) { + const coverage = count / cluster.count; + if (coverage < minCoverage) { + continue; + } + + const info = types.get(typeIdx); + const docFreq = df.get(typeIdx) ?? 1; + const idf = Math.log((totalClusters + 1) / (docFreq + 1)); + const depth = info?.depth ?? 0; + const score = coverage * (idf + 0.05 * depth); + + if ( + !best || + score > best.score || + (score === best.score && depth > (types.get(best.typeIdx)?.depth ?? 0)) + ) { + best = { typeIdx, coverage, score }; + } + } + + return best; +} + +function distinctiveLabel( + cluster: ClusterNode, + directDf: Map, + closureDf: Map, + totalClusters: number, + types: TypeRegistry, +): ClusterLabel { + const minCoverage = cluster.kind === "other" ? 0.35 : 0.5; + + const candidate = + bestCandidate( + cluster, + cluster.mass.direct, + directDf, + minCoverage, + totalClusters, + types, + ) ?? + bestCandidate( + cluster, + cluster.mass.closure, + closureDf, + 0.5, + totalClusters, + types, + ); + + if (candidate) { + const info = types.get(candidate.typeIdx); + const title = info?.title ?? "Unknown"; + const prefix = candidate.coverage < 0.65 ? "Mostly " : ""; + return new ClusterLabel( + `${prefix}${title}`, + candidate.typeIdx, + candidate.coverage, + candidate.coverage < 0.65, + ); + } + + return new ClusterLabel("Mixed entities"); +} + +function findMergeTarget( + small: TypeSetGroup, + anchorIndex: Map, + config: VizConfig, +): ClusterId { + const candidates = new Map(); + for (const typeIdx of small.closure.members()) { + for (const anchor of anchorIndex.get(typeIdx) ?? []) { + candidates.set(anchor.key, anchor); + } + } + + let best: + | { + group: TypeSetGroup; + rawJaccard: number; + directSuperset: boolean; + adjustedScore: number; + } + | undefined; + + for (const anchor of candidates.values()) { + const rawJaccard = small.closure.jaccard(anchor.closure); + const directSuperset = small.directTypeIdxs.isSubsetOf( + anchor.directTypeIdxs, + ); + const adjustedScore = rawJaccard + (directSuperset ? 0.1 : 0); + + if ( + !best || + adjustedScore > best.adjustedScore || + (adjustedScore === best.adjustedScore && anchor.key < best.group.key) + ) { + best = { group: anchor, rawJaccard, directSuperset, adjustedScore }; + } + } + + if ( + best && + (best.rawJaccard >= config.mergeJaccardMin || + (best.directSuperset && best.rawJaccard >= config.mergeSubsetJaccardMin)) + ) { + return best.group.standaloneClusterId; + } + + const primaryType = small.directTypeIdxs.items[0] ?? 0; + return ClusterId(`cluster:other:${primaryType}`); +} + +function buildAnchorIndex( + typeSets: TypeSetStore, +): Map { + const index = new Map(); + + for (const group of typeSets) { + if (!group.isStandalone || group.count === 0) { + continue; + } + + for (const typeIdx of group.closure.members()) { + let list = index.get(typeIdx); + if (!list) { + list = []; + index.set(typeIdx, list); + } + list.push(group); + } + } + + return index; +} + +const CLUSTER_GOLDEN_ANGLE_DEG = 137.508; +const SUBCLUSTER_HUE_SPAN_DEG = 18; + +function clusterDepth(cluster: ClusterNode): number { + let depth = 0; + let parent = cluster.parent; + while (parent?.kind !== "root") { + depth += 1; + parent = parent?.parent ?? null; + } + return depth; +} + +function inheritedPrimaryType(cluster: ClusterNode): { + typeIdx: TypeIdx | null; + inherited: boolean; +} { + if (cluster.label.primaryType !== null) { + return { typeIdx: cluster.label.primaryType, inherited: false }; + } + let parent = cluster.parent; + while (parent && parent.kind !== "root") { + if (parent.label.primaryType !== null) { + return { typeIdx: parent.label.primaryType, inherited: true }; + } + parent = parent.parent; + } + return { typeIdx: null, inherited: false }; +} + +function siblingTint(cluster: ClusterNode): number { + const siblings = cluster.parent?.children ?? []; + const count = siblings.length; + if (count <= 1) { + return 0; + } + const index = Math.max( + 0, + siblings.findIndex((sibling) => sibling.id === cluster.id), + ); + return (index / (count - 1) - 0.5) * SUBCLUSTER_HUE_SPAN_DEG; +} + +export function colorForCluster( + cluster: ClusterNode, + types: TypeRegistry, +): Color { + const depth = clusterDepth(cluster); + const { typeIdx: primaryType, inherited } = inheritedPrimaryType(cluster); + if (primaryType === null) { + const alpha = depth > 0 ? 95 : 145; + return [126, 142, 160, alpha]; + } + + const info = types.get(primaryType); + const rootIdx = info?.rootIdxs[0] ?? primaryType; + const slot = types.colorSlot(rootIdx); + if (slot === undefined) { + const alpha = depth > 0 ? 95 : 145; + return [ + graphColors.fallbackEntity[0], + graphColors.fallbackEntity[1], + graphColors.fallbackEntity[2], + alpha, + ]; + } + + const rawHue = + slot * CLUSTER_GOLDEN_ANGLE_DEG + (inherited ? siblingTint(cluster) : 0); + const hue = ((rawHue % 360) + 360) % 360; + const mixed = cluster.label.isMixed || cluster.label.coverage < 0.55; + const [red, green, blue] = hslToRgb( + hue, + inherited ? 0.42 : mixed ? 0.36 : 0.6, + inherited ? 0.6 + Math.min(depth, 3) * 0.045 : depth > 0 ? 0.68 : 0.54, + ); + return [ + red, + green, + blue, + inherited ? 150 : depth > 0 ? (mixed ? 110 : 150) : mixed ? 165 : 215, + ]; +} + +/** + * Owns the cluster hierarchy: the node registry, lazy subdivision + * state, and embedding membership. All tree mutations go through + * this class so invariants (disjoint children, consistent counts, + * valid parent refs) are maintained in one place. + */ +export class ClusterTree { + readonly #nodes = new Map(); + readonly #root: ClusterNode; + readonly #subdivisionRequested = new Set(); + + constructor() { + this.#root = new ClusterNode(ClusterId("cluster:root"), "root", { + source: "groups", + keys: [], + }); + this.#register(this.#root); + } + + get root(): ClusterNode { + return this.#root; + } + + get(id: ClusterId): ClusterNode | undefined { + return this.#nodes.get(id); + } + + has(id: ClusterId): boolean { + return this.#nodes.has(id); + } + + get size(): number { + return this.#nodes.size; + } + + get isEmpty(): boolean { + return this.#nodes.size <= 1; + } + + values(): IterableIterator { + return this.#nodes.values(); + } + + atomicSum(): number { + let sum = 0; + for (const node of this.#nodes.values()) { + if (node.kind === "type-set" || node.kind === "other") { + sum += node.count; + } + } + return sum; + } + + /** Human-readable dump of the whole tree, for debugging (label, count, kind, + * radius, child count, id), indented by depth. */ + debugDump(): string { + const lines: string[] = []; + const visit = (node: ClusterNode, depth: number): void => { + lines.push( + `${" ".repeat(depth)}"${node.label.text}" (${node.count}) ` + + `[${node.kind}] r=${Math.round(node.circle.radius)} ` + + `nchildren=${node.children.length} id=${node.id}`, + ); + for (const child of node.children) { + visit(child, depth + 1); + } + }; + visit(this.#root, 0); + return lines.join("\n"); + } + + /** + * Full pipeline: classify, materialize, label, hierarchy, layout. + * Cold-start path used on the first ingest batch. + */ + rebuild( + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + totalNodeCount: number, + ): void { + for (const group of typeSets) { + group.recomputeClosure(types); + } + + this.#classifyAndMerge(typeSets, config); + this.#clearAll(); + this.#materializeClusters(typeSets); + this.#computeLabels(types); + this.#buildDisplayHierarchy(types, config); + this.#layoutTopLevel(totalNodeCount); + } + + /** + * Incremental path. Handles count growth, new groups, + * threshold promotion, and re-targeting of small groups. + */ + updateIncrementally( + deltas: readonly IngestDelta[], + typeSets: TypeSetStore, + types: TypeRegistry, + config: VizConfig, + totalNodeCount: number, + ): void { + const dirtyIds = new Set(); + let needsHierarchyRebuild = false; + + for (const { groupKey } of deltas) { + typeSets.get(groupKey)?.recomputeClosure(types); + } + + const anchorIndex = buildAnchorIndex(typeSets); + + for (const { groupKey, delta, isNewGroup, previousCount } of deltas) { + const group = typeSets.get(groupKey); + if (!group) { + continue; + } + + const crossedThreshold = + !group.isStandalone && + previousCount < config.minStandaloneTypeSet && + group.count >= config.minStandaloneTypeSet; + + if (crossedThreshold) { + const oldNode = this.#nodes.get(group.assignedClusterId); + if (oldNode) { + oldNode.removeGroupMass(group, previousCount); + dirtyIds.add(oldNode.id); + } + + group.isStandalone = true; + group.assignedClusterId = group.standaloneClusterId; + + const newNode = this.#ensureNode(group.standaloneClusterId, "type-set"); + newNode.addGroupMass(group); + + if (oldNode) { + const angle = stableHashToAngle(newNode.id); + newNode.circle.x = + oldNode.circle.x + Math.cos(angle) * oldNode.circle.radius * 0.25; + newNode.circle.y = + oldNode.circle.y + Math.sin(angle) * oldNode.circle.radius * 0.25; + } + + dirtyIds.add(newNode.id); + needsHierarchyRebuild = true; + + const candidates = this.#smallGroupsSharingTypes(group, typeSets); + for (const small of candidates) { + const newTarget = findMergeTarget(small, anchorIndex, config); + if (newTarget !== small.assignedClusterId) { + const prevNode = this.#nodes.get(small.assignedClusterId); + if (prevNode) { + prevNode.removeGroupMass(small, small.count); + dirtyIds.add(prevNode.id); + } + + small.assignedClusterId = newTarget; + const targetNode = this.#ensureNode( + newTarget, + newTarget.startsWith("cluster:other:") ? "other" : "type-set", + ); + targetNode.addGroupMass(small); + dirtyIds.add(newTarget); + needsHierarchyRebuild = true; + } + } + } else if (isNewGroup) { + if (group.count >= config.minStandaloneTypeSet) { + group.isStandalone = true; + group.assignedClusterId = group.standaloneClusterId; + } else { + group.isStandalone = false; + group.assignedClusterId = findMergeTarget(group, anchorIndex, config); + } + + const clusterId = group.assignedClusterId; + const kind: ClusterKind = clusterId.startsWith("cluster:other:") + ? "other" + : "type-set"; + const node = this.#ensureNode(clusterId, kind); + node.addGroupMass(group); + dirtyIds.add(clusterId); + needsHierarchyRebuild = true; + } else { + const node = this.#nodes.get(group.assignedClusterId); + if (node) { + node.incrementGroupMass(group, delta); + dirtyIds.add(node.id); + } + } + } + + this.#propagateCountsToRoot(dirtyIds); + + for (const [, node] of this.#nodes) { + if (node.kind !== "root" && node.count === 0) { + this.#unregister(node); + needsHierarchyRebuild = true; + } + } + + this.#relabelDirty(dirtyIds, types); + + if (needsHierarchyRebuild) { + this.#clearFamilyNodes(); + this.#buildDisplayHierarchy(types, config); + } + + this.#stableLayout(totalNodeCount); + } + + /** + * Lazy subdivision trigger. Synchronously runs community + * detection when a leaf cluster is too large for entity reveal. + * Returns true if children were created. + */ + ensureSubclusters( + node: ClusterNode, + typeSets: TypeSetStore, + links: LinkStore, + config: VizConfig, + ): boolean { + if (node.count <= config.entityRevealMax) { + return false; + } + if (node.children.length > 0) { + return false; + } + const entityIdxs = this.#collectEntityIdxs(node, typeSets); + const childNodes = subclusterByLinks(node, entityIdxs, links, config); + if (childNodes.length < 2) { + return false; + } + + for (const child of childNodes) { + node.addChild(child); + this.#register(child); + } + + this.#layoutChildrenInParent(node); + if (config.debug) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][cluster-tree] subdivided ${node.id} (${node.count}) ` + + `into ${childNodes.length} groups:`, + childNodes.map((ch) => `${ch.label.text} (${ch.count})`).join(", "), + ); + } + return true; + } + + needsEmbeddingSubdivision(node: ClusterNode, config: VizConfig): boolean { + if (node.count <= config.entityRevealMax) { + return false; + } + // Entity-bucket children are placeholders; still needs real subdivision. + const hasRealChildren = + node.children.length >= 2 && + node.children.some((child) => child.kind !== "entity-bucket"); + if (hasRealChildren) { + return false; + } + return !this.#subdivisionRequested.has(node.id); + } + + markSubdivisionRequested(id: ClusterId): void { + this.#subdivisionRequested.add(id); + } + + applyEmbeddingResult( + id: ClusterId, + childAssignments: readonly { + readonly childId: ClusterId; + readonly count: number; + readonly memberIdxs: Int32Array; + }[], + ): void { + const node = this.#nodes.get(id); + if (!node) { + return; + } + + // No results (e.g. missing embeddings): keep deterministic + // partition and don't retry. + if (childAssignments.length === 0) { + return; + } + + node.clearChildren(); + + for (const { childId, count, memberIdxs } of childAssignments) { + const members = new Column( + Int32Array, + memberIdxs.length, + ); + for (const idx of memberIdxs) { + members.push(idx as EntityIdx); + } + const child = new ClusterNode(childId, "embedding", { + source: "direct", + members, + }); + child.count = count; + child.label = new ClusterLabel( + `Similar group ${node.children.length + 1}`, + ); + + node.addChild(child); + this.#register(child); + } + + this.#layoutChildrenInParent(node); + } + + /** + * Replace a node's label text — used to drop a distinctive-feature name over the + * placeholder of an embedding group ("Similar group n", {@link applyEmbeddingResult}) or a + * grouping-fallback group ("Group n": link-signature `community` + `entity-bucket` chunks). + * None of those kinds are touched by `#computeLabels`/`#relabelDirty` (those are type-set + * only), so the new text survives subsequent commits. + */ + setLabelText(id: ClusterId, text: string): void { + const node = this.#nodes.get(id); + if (node) { + node.label = new ClusterLabel(text); + } + } + + #collectEntityIdxs( + node: ClusterNode, + typeSets: TypeSetStore, + ): Column { + if (node.membership.source === "direct") { + return node.membership.members; + } + + let total = 0; + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + total += group.entityIdxs.length; + } + } + + const result = new Column(Int32Array, total); + for (const key of node.membership.keys) { + const group = typeSets.get(key); + if (group) { + for (const idx of group.entityIdxs) { + result.push(idx); + } + } + } + return result; + } + + #register(node: ClusterNode): void { + this.#nodes.set(node.id, node); + } + + #unregister(node: ClusterNode): void { + node.parent?.removeChild(node); + this.#nodes.delete(node.id); + } + + #clearAll(): void { + this.#root.clearChildren(); + this.#nodes.clear(); + this.#subdivisionRequested.clear(); + this.#register(this.#root); + } + + #clearFamilyNodes(): void { + for (const [, node] of this.#nodes) { + if (node.kind === "family") { + node.clearChildren(); + this.#unregister(node); + } + } + } + + #ensureNode(id: ClusterId, kind: ClusterKind): ClusterNode { + let node = this.#nodes.get(id); + if (!node) { + node = new ClusterNode(id, kind, { source: "groups", keys: [] }); + this.#register(node); + } + return node; + } + + #classifyAndMerge(typeSets: TypeSetStore, config: VizConfig): void { + const anchors: TypeSetGroup[] = []; + const small: TypeSetGroup[] = []; + + for (const group of typeSets) { + if (group.count === 0) { + continue; + } + if (group.count >= config.minStandaloneTypeSet) { + anchors.push(group); + } else { + small.push(group); + } + } + + if (anchors.length === 0 && small.length > 0) { + const promoted = [...small] + .sort( + (lhs, rhs) => rhs.count - lhs.count || lhs.key.localeCompare(rhs.key), + ) + .slice(0, Math.min(8, small.length)); + for (const group of promoted) { + anchors.push(group); + } + } + + const anchorIndex = new Map(); + for (const anchor of anchors) { + for (const typeIdx of anchor.closure.members()) { + let list = anchorIndex.get(typeIdx); + if (!list) { + list = []; + anchorIndex.set(typeIdx, list); + } + list.push(anchor); + } + } + + for (const anchor of anchors) { + anchor.assignedClusterId = anchor.standaloneClusterId; + anchor.isStandalone = true; + } + + for (const group of small) { + if (anchors.includes(group)) { + group.assignedClusterId = group.standaloneClusterId; + group.isStandalone = true; + continue; + } + group.assignedClusterId = findMergeTarget(group, anchorIndex, config); + group.isStandalone = false; + } + } + + #materializeClusters(typeSets: TypeSetStore): void { + const groupsByCluster = new Map(); + for (const group of typeSets) { + if (group.count === 0) { + continue; + } + let list = groupsByCluster.get(group.assignedClusterId); + if (!list) { + list = []; + groupsByCluster.set(group.assignedClusterId, list); + } + list.push(group); + } + + for (const [clusterId, groups] of groupsByCluster) { + const kind: ClusterKind = clusterId.startsWith("cluster:other:") + ? "other" + : "type-set"; + const node = new ClusterNode(clusterId, kind, { + source: "groups", + keys: [], + }); + + for (const group of groups) { + node.addGroupMass(group); + } + + this.#root.addChild(node); + this.#root.count += node.count; + this.#register(node); + } + } + + #computeLabels(types: TypeRegistry): void { + const atomic = [...this.#nodes.values()].filter( + (node) => node.kind === "type-set" || node.kind === "other", + ); + + const directDf = documentFrequency(atomic, "direct"); + const closureDf = documentFrequency(atomic, "closure"); + + for (const node of atomic) { + node.label = distinctiveLabel( + node, + directDf, + closureDf, + atomic.length, + types, + ); + } + } + + #relabelDirty(dirtyIds: Set, types: TypeRegistry): void { + const atomic = [...this.#nodes.values()].filter( + (node) => node.kind === "type-set" || node.kind === "other", + ); + + const directDf = documentFrequency(atomic, "direct"); + const closureDf = documentFrequency(atomic, "closure"); + + for (const id of dirtyIds) { + const node = this.#nodes.get(id); + if (!node || (node.kind !== "type-set" && node.kind !== "other")) { + continue; + } + node.label = distinctiveLabel( + node, + directDf, + closureDf, + atomic.length, + types, + ); + } + } + + #makeRollupNode( + id: ClusterId, + kind: ClusterKind, + sourceNodes: readonly ClusterNode[], + label?: ClusterLabel, + ): ClusterNode { + const node = new ClusterNode(id, kind, { source: "groups", keys: [] }); + for (const source of sourceNodes) { + node.count += source.count; + node.mass.absorb(source.mass); + } + // Without a label a rollup renders as a nameless "(count)" bubble. + if (label) { + node.label = label; + } + return node; + } + + #bucketByPrimaryRoot( + atomicNodes: readonly ClusterNode[], + types: TypeRegistry, + ): Map { + const buckets = new Map(); + + for (const node of atomicNodes) { + const primaryType = node.label.primaryType; + const rootIdx = + primaryType !== null ? types.get(primaryType)?.rootIdxs[0] : undefined; + + let list = buckets.get(rootIdx); + if (!list) { + list = []; + buckets.set(rootIdx, list); + } + list.push(node); + } + + return buckets; + } + + #buildBoundedRollupSubtree( + parent: ClusterNode, + children: ClusterNode[], + maxChildren: number, + ): void { + if (children.length <= maxChildren) { + for (const child of stableSortNodes(children)) { + parent.addChild(child); + } + return; + } + + const sorted = stableSortNodes(children); + const direct = sorted.slice(0, maxChildren - 1); + const overflow = sorted.slice(maxChildren - 1); + + for (const child of direct) { + parent.addChild(child); + } + + if (overflow.length > 0) { + const other = this.#makeRollupNode( + ClusterId(`cluster:family:overflow:${parent.id}`), + "family", + overflow, + new ClusterLabel("More"), + ); + parent.addChild(other); + this.#register(other); + + if (overflow.length > maxChildren) { + this.#buildBoundedRollupSubtree(other, overflow, maxChildren); + } else { + for (const child of overflow) { + other.addChild(child); + } + } + } + } + + #buildDisplayHierarchy(types: TypeRegistry, config: VizConfig): void { + this.#root.clearChildren(); + + const atomicNodes = [...this.#nodes.values()].filter( + (node) => node.kind === "type-set" || node.kind === "other", + ); + + const buckets = this.#bucketByPrimaryRoot(atomicNodes, types); + const topLevel: ClusterNode[] = []; + + for (const [rootTypeIdx, bucketNodes] of buckets) { + if (bucketNodes.length === 1) { + topLevel.push(bucketNodes[0]!); + continue; + } + + const rootName = + rootTypeIdx !== undefined ? (types.get(rootTypeIdx)?.title ?? "") : ""; + const rootTitle = rootName.length > 0 ? rootName : "Other"; + const family = this.#makeRollupNode( + ClusterId(`cluster:family:${rootTypeIdx ?? "unknown"}`), + "family", + bucketNodes, + new ClusterLabel(rootTitle, rootTypeIdx ?? null, 1, false), + ); + this.#register(family); + topLevel.push(family); + + this.#buildBoundedRollupSubtree( + family, + bucketNodes, + config.maxChildrenPerParent, + ); + } + + if (topLevel.length <= config.maxChildrenPerParent) { + for (const child of stableSortNodes(topLevel)) { + this.#root.addChild(child); + } + } else { + this.#buildBoundedRollupSubtree( + this.#root, + topLevel, + config.maxChildrenPerParent, + ); + } + + this.#root.count = this.#root.children.reduce( + (sum, child) => sum + child.count, + 0, + ); + this.#root.label = new ClusterLabel("All entities", null, 1, true); + } + + /** + * Bottom-up circle-packing radii. A family rollup grows to enclose its + * (already-sized) children so two small siblings never overlap inside a + * container that was otherwise sized by its small aggregate count. Every other + * node is sized by entity count (area proportional to count). Subdivided type-sets are + * sized/packed top-down elsewhere (#layoutChildrenInParent) and so are left + * count-based here, drilling into one must not reflow the top level. The + * per-node radius is then read by the force layout and the renderer. + */ + #assignRadii(): void { + for (const child of this.#root.children) { + this.#sizeNodeRadius(child); + } + } + + #sizeNodeRadius(node: ClusterNode): void { + const countRadius = Math.max( + LEAF_MIN_RADIUS, + Math.sqrt(node.count) * RADIUS_PER_SQRT_COUNT, + ); + + if (node.kind === "family" && node.children.length > 0) { + for (const child of node.children) { + this.#sizeNodeRadius(child); + } + const childRadii = node.children.map((child) => child.circle.radius); + const fit = enclosingRadius(childRadii, ENCLOSE_GAP) + ENCLOSE_PADDING; + node.circle.radius = Math.max(countRadius, fit); + } else { + node.circle.radius = countRadius; + } + } + + #layoutTopLevel(totalCount: number): void { + const children = this.#root.children; + if (children.length === 0) { + return; + } + + this.#assignRadii(); + + const angleStep = (2 * Math.PI) / children.length; + const baseRadius = Math.max(100, Math.sqrt(totalCount) * 4); + + for (let idx = 0; idx < children.length; idx++) { + const child = children[idx]!; + child.circle.radius = Math.max(TOP_LEVEL_MIN_RADIUS, child.circle.radius); + child.circle.x = Math.cos(angleStep * idx) * baseRadius; + child.circle.y = Math.sin(angleStep * idx) * baseRadius; + } + + resolveCollisions(children, 80); + } + + #stableLayout(totalCount: number): void { + const children = this.#root.children; + if (children.length === 0) { + return; + } + + this.#assignRadii(); + + const baseRadius = Math.max(100, Math.sqrt(totalCount) * 4); + + for (const child of children) { + const isNew = child.circle.isOrigin; + child.circle.radius = Math.max(TOP_LEVEL_MIN_RADIUS, child.circle.radius); + + if (isNew) { + const sibling = mostSimilarSibling(child, children); + if (sibling) { + const angle = stableHashToAngle(child.id); + child.circle.x = + sibling.circle.x + + Math.cos(angle) * (sibling.circle.radius + child.circle.radius + 8); + child.circle.y = + sibling.circle.y + + Math.sin(angle) * (sibling.circle.radius + child.circle.radius + 8); + } else { + const angle = stableHashToAngle(child.id); + child.circle.x = Math.cos(angle) * baseRadius; + child.circle.y = Math.sin(angle) * baseRadius; + } + } + } + + resolveCollisions(children, 80); + } + + #layoutChildrenInParent(parent: ClusterNode): void { + const children = parent.children; + if (children.length === 0) { + return; + } + + const totalCount = children.reduce((sum, child) => sum + child.count, 0); + const padding = 4; + + // Size children proportional to their entity count. + // Reserve space so child + placement fits inside the parent. + for (const child of children) { + child.circle.radius = Math.max( + 8, + parent.circle.radius * Math.sqrt(child.count / totalCount) * 0.6, + ); + } + + // Place on a ring inside the parent. The ring radius is chosen + // so that child circles don't immediately poke out. + const maxChildR = children.reduce( + (max, child) => Math.max(max, child.circle.radius), + 0, + ); + const availableRadius = parent.circle.radius - maxChildR - padding; + const innerRadius = Math.max(0, availableRadius * 0.65); + + const angleStep = (2 * Math.PI) / children.length; + for (let idx = 0; idx < children.length; idx++) { + children[idx]!.circle.x = + parent.circle.x + Math.cos(angleStep * idx) * innerRadius; + children[idx]!.circle.y = + parent.circle.y + Math.sin(angleStep * idx) * innerRadius; + } + + resolveCollisions(children, 80); + clampChildrenToParent(children, parent, padding); + } + + #propagateCountsToRoot(dirtyIds: Set): void { + const visited = new Set(); + + for (const id of dirtyIds) { + let current = this.#nodes.get(id); + while (current?.parent && !visited.has(current.parent.id)) { + visited.add(current.parent.id); + const parentNode = current.parent; + parentNode.count = parentNode.children.reduce( + (sum, child) => sum + child.count, + 0, + ); + current = parentNode; + } + } + } + + #smallGroupsSharingTypes( + promoted: TypeSetGroup, + typeSets: TypeSetStore, + ): TypeSetGroup[] { + const result: TypeSetGroup[] = []; + for (const group of typeSets) { + if ( + group.isStandalone || + group.count === 0 || + group.key === promoted.key + ) { + continue; + } + if (group.closure.jaccard(promoted.closure) > 0) { + result.push(group); + } + } + return result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts new file mode 100644 index 00000000000..c8c77e2df62 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts @@ -0,0 +1,451 @@ +/** + * Community detection for sub-clustering large type-set clusters. + * + * Runs lazily when a cluster is about to open and is too large to + * show individual entities. Uses connected components first, then + * bounded label propagation for large components. + */ +import { ClusterId } from "../../ids"; +import { Column } from "../collections/column"; +import { + type CsrGraph, + buildInducedCsr, + connectedComponents, +} from "../csr-graph"; +import { ClusterLabel, ClusterNode } from "./cluster-tree"; + +import type { VizConfig } from "../../config"; +import type { EntityIdx } from "../../ids"; +import type { LinkStore } from "../stores/link-store"; + +/* eslint-disable no-bitwise */ +function deterministicShuffle(indices: number[], seed: number): number[] { + const result = [...indices]; + let state = seed * 2654435761; + + for (let idx = result.length - 1; idx > 0; idx--) { + state = (state ^ (state << 13)) | 0; + state = (state ^ (state >>> 17)) | 0; + state = (state ^ (state << 5)) | 0; + const target = (state >>> 0) % (idx + 1); + const temp = result[idx]!; + result[idx] = result[target]!; + result[target] = temp; + } + + return result; +} +/* eslint-enable no-bitwise */ + +export function boundedLabelPropagation( + graph: CsrGraph, + component: number[], +): Int32Array { + const nodeCount = graph.nodeIds.length; + const labels = new Int32Array(nodeCount); + const sizes = new Int32Array(nodeCount); + + for (const localIdx of component) { + labels[localIdx] = localIdx; + sizes[localIdx] = 1; + } + + const maxIterations = 20; + const alpha = 0.35; + const stabilityBias = 0.01; + + for (let iteration = 0; iteration < maxIterations; iteration++) { + let changed = 0; + const order = deterministicShuffle(component, iteration); + + for (const node of order) { + const current = labels[node]!; + const scores = new Map(); + + for ( + let edge = graph.offsets[node]!; + edge < graph.offsets[node + 1]!; + edge++ + ) { + const neighbor = graph.neighbors[edge]!; + const label = labels[neighbor]!; + const weight = graph.weights[edge]!; + scores.set(label, (scores.get(label) ?? 0) + weight); + } + + scores.set(current, (scores.get(current) ?? 0) + stabilityBias); + + let bestLabel = current; + let bestScore = -Infinity; + + for (const [label, rawScore] of scores) { + const sizePenalty = Math.max(1, sizes[label]!) ** alpha; + const score = rawScore / sizePenalty; + if (score > bestScore || (score === bestScore && label < bestLabel)) { + bestScore = score; + bestLabel = label; + } + } + + if (bestLabel !== current) { + sizes[current]!--; + sizes[bestLabel]!++; + labels[node] = bestLabel; + changed++; + } + } + + if (changed / component.length < 0.005) { + break; + } + } + + return labels; +} + +function labelsToCommunities( + labels: Int32Array, + component: number[], +): number[][] { + const communities = new Map(); + + for (const localIdx of component) { + const label = labels[localIdx]!; + let list = communities.get(label); + if (!list) { + list = []; + communities.set(label, list); + } + list.push(localIdx); + } + + return [...communities.values()]; +} + +function normalizeCommunitySizes( + communities: number[][], + graph: CsrGraph, + config: VizConfig, +): EntityIdx[][] { + const result: EntityIdx[][] = []; + const tiny: EntityIdx[] = []; + + for (const community of communities) { + const entityIdxs = community.map((local) => graph.nodeIds.get(local)); + + if (entityIdxs.length < config.communityMinSize) { + for (const idx of entityIdxs) { + tiny.push(idx); + } + } else if (entityIdxs.length > config.communityMaxSize) { + for ( + let start = 0; + start < entityIdxs.length; + start += config.communityMaxSize + ) { + result.push(entityIdxs.slice(start, start + config.communityMaxSize)); + } + } else { + result.push(entityIdxs); + } + } + + if (tiny.length > 0) { + result.push(tiny); + } + + return result; +} + +function topDegreeEntity( + members: EntityIdx[], + links: LinkStore, +): EntityIdx | undefined { + let bestIdx: EntityIdx | undefined; + let bestDegree = 0; + + for (const entityIdx of members) { + const degree = links.linksForEntity(entityIdx).length; + if (degree > bestDegree) { + bestDegree = degree; + bestIdx = entityIdx; + } + } + + return bestIdx; +} + +function collectLinkFeatures( + members: EntityIdx[], + links: LinkStore, +): Map { + const features = new Map(); + const memberSet = new Set(members); + + for (const entityIdx of members) { + const endpoints = links.linksForEntity(entityIdx); + for (const endpoint of endpoints) { + const isInternal = memberSet.has(endpoint.otherIdx); + const prefix = isInternal ? "int" : "ext"; + const key = `${prefix}:${endpoint.direction}:${endpoint.typeSetIdx}`; + features.set(key, (features.get(key) ?? 0) + 1); + } + } + + return features; +} + +function featureKeyToLabel(key: string): string { + const parts = key.split(":"); + const scope = parts[0] === "int" ? "Internal" : "External"; + const direction = parts[1] === "out" ? "outgoing" : "incoming"; + return `${scope} ${direction} links`; +} + +/** + * Label communities using overrepresented link features, + * scored with TF-IDF across sibling communities. + */ +function labelAllCommunities( + communityMembers: EntityIdx[][], + children: ClusterNode[], + links: LinkStore, +): void { + if (communityMembers.length === 0) { + return; + } + + const allFeatures: Map[] = communityMembers.map((members) => + collectLinkFeatures(members, links), + ); + + const df = new Map(); + for (const features of allFeatures) { + for (const key of features.keys()) { + df.set(key, (df.get(key) ?? 0) + 1); + } + } + + const totalCommunities = communityMembers.length; + + for (let idx = 0; idx < children.length; idx++) { + const features = allFeatures[idx]!; + const memberCount = communityMembers[idx]!.length; + const child = children[idx]!; + + let bestKey: string | undefined; + let bestScore = -Infinity; + let bestCoverage = 0; + + for (const [featureKey, count] of features) { + const coverage = count / memberCount; + if (coverage < 0.25) { + continue; + } + + const docFreq = df.get(featureKey) ?? 1; + const idf = Math.log((totalCommunities + 1) / (docFreq + 1)); + const score = coverage * idf; + + if (score > bestScore) { + bestScore = score; + bestKey = featureKey; + bestCoverage = coverage; + } + } + + if (bestKey) { + child.label = new ClusterLabel( + featureKeyToLabel(bestKey), + null, + bestCoverage, + bestCoverage < 0.5, + ); + } else { + const hub = topDegreeEntity(communityMembers[idx]!, links); + child.label = hub + ? new ClusterLabel(`Around entity ${hub}`) + : new ClusterLabel(`Community ${idx + 1}`); + } + } +} + +/* eslint-disable no-bitwise */ +function linkSignatureKey( + entityIdx: EntityIdx, + links: LinkStore, + maxBuckets: number, +): string { + const endpoints = links.linksForEntity(entityIdx); + if (endpoints.length === 0) { + return "isolated"; + } + + const features = new Set(); + for (const endpoint of endpoints) { + features.add(`${endpoint.direction}:${endpoint.typeSetIdx}`); + } + + const sorted = [...features].sort(); + const key = sorted.join("|"); + + let hash = 0; + for (let idx = 0; idx < key.length; idx++) { + hash = ((hash << 5) - hash + key.charCodeAt(idx)) | 0; + } + return `sig:${(hash >>> 0) % maxBuckets}`; +} +/* eslint-enable no-bitwise */ + +function columnFromIndices( + indices: ArrayLike, +): Column { + const col = new Column(Int32Array, indices.length); + for (let idx = 0; idx < indices.length; idx++) { + col.push(indices[idx]!); + } + return col; +} + +function coarseLinkSignatureBuckets( + cluster: ClusterNode, + entityIdxs: Column, + links: LinkStore, + config: VizConfig, +): ClusterNode[] { + const buckets = new Map(); + + for (const entityIdx of entityIdxs) { + const key = linkSignatureKey(entityIdx, links, config.maxChildrenPerParent); + + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(entityIdx); + } + + const normalized = [...buckets.values()]; + + const children: ClusterNode[] = normalized.map((memberIdxs, idx) => { + const members = columnFromIndices(memberIdxs); + const child = new ClusterNode( + ClusterId(`${cluster.id}:bucket:${idx}`), + "community", + { source: "direct", members }, + ); + child.count = memberIdxs.length; + child.label = new ClusterLabel(`Group ${idx + 1}`); + return child; + }); + + labelAllCommunities(normalized, children, links); + + return children; +} + +/** + * Last-resort partitioning when link-based community detection + * can't produce meaningful groups (e.g. 0 internal edges). + * Splits entities into roughly equal chunks. These are placeholders + * that embedding k-means can later replace with semantic groups. + */ +function deterministicPartition( + cluster: ClusterNode, + entityIdxs: Column, + config: VizConfig, +): ClusterNode[] { + const targetSize = Math.floor( + config.entityRevealMax * config.embeddingTargetLeafFillRatio, + ); + const kk = Math.max( + 2, + Math.min(config.embeddingMaxK, Math.ceil(entityIdxs.length / targetSize)), + ); + + const children: ClusterNode[] = []; + for (let idx = 0; idx < kk; idx++) { + const start = Math.floor((idx * entityIdxs.length) / kk); + const end = Math.floor(((idx + 1) * entityIdxs.length) / kk); + const members = entityIdxs.slice(start, end); + const child = new ClusterNode( + ClusterId(`${cluster.id}:bucket:${idx}`), + "entity-bucket", + { source: "direct", members }, + ); + child.count = end - start; + child.label = new ClusterLabel(`Group ${idx + 1}`); + children.push(child); + } + return children; +} + +/** + * Full sub-clustering pipeline for a single cluster. + * Returns child ClusterNodes, or empty array if the cluster + * is small enough to show entities directly. + * + * Cascade: community detection -> deterministic partition. + * Always returns >= 2 children for clusters above entityRevealMax. + */ +export function subclusterByLinks( + cluster: ClusterNode, + entityIdxs: Column, + links: LinkStore, + config: VizConfig, +): ClusterNode[] { + if (entityIdxs.length <= config.entityRevealMax) { + return []; + } + + if (entityIdxs.length > config.communityWorkerNodeCap) { + return coarseLinkSignatureBuckets(cluster, entityIdxs, links, config); + } + + const csr = buildInducedCsr(entityIdxs, links); + + // No internal edges: community detection can't work. + if (csr.neighbors.length === 0) { + return deterministicPartition(cluster, entityIdxs, config); + } + + const components = connectedComponents(csr); + const rawCommunities: number[][] = []; + + for (const component of components) { + if (component.length <= config.communityMaxSize) { + rawCommunities.push(component); + continue; + } + + const labels = boundedLabelPropagation(csr, component); + const split = labelsToCommunities(labels, component); + for (const community of split) { + rawCommunities.push(community); + } + } + + const normalized = normalizeCommunitySizes(rawCommunities, csr, config); + + // Community detection produced too few groups. + if (normalized.length < 2) { + return deterministicPartition(cluster, entityIdxs, config); + } + + const children: ClusterNode[] = normalized.map((memberIdxs, idx) => { + const members = columnFromIndices(memberIdxs); + const child = new ClusterNode( + ClusterId(`${cluster.id}:community:${idx}`), + "community", + { source: "direct", members }, + ); + child.count = memberIdxs.length; + child.label = new ClusterLabel(`Community ${idx + 1}`); + return child; + }); + + labelAllCommunities(normalized, children, links); + + return children; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts new file mode 100644 index 00000000000..e7118b2aaec --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts @@ -0,0 +1,208 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { ClusterId, EntityIdx } from "../../ids"; +import { nameClustersByDistinctiveFeatures } from "./distinctive-cluster-label"; + +import type { + ClusterMembers, + FeatureDescriptor, + FeatureSource, + NumericDimension, + NumericReading, +} from "./distinctive-cluster-label"; + +interface MemberSpec { + readonly keys: readonly string[]; + readonly numerics: readonly NumericReading[]; +} + +/** A tiny in-memory {@link FeatureSource} so the namer is tested without the worker's stores. */ +class FeatureModel { + readonly #members: MemberSpec[] = []; + readonly #descriptors = new Map(); + readonly #numericDimensions = new Map(); + + /** An exact `Title = "value"` feature key (auto-registers its descriptor). */ + exact(baseUrl: string, title: string, value: string): string { + const key = `p\u0000${baseUrl}\u0000${value}`; + this.#descriptors.set(key, { + group: `prop\u0000${baseUrl}`, + text: `${title} = "${value}"`, + sortKey: title, + }); + return key; + } + + /** An outgoing link/target-type feature key (auto-registers its descriptor). */ + link(targetTitle: string): string { + const key = `lt\u0000out\u0000${targetTitle}`; + this.#descriptors.set(key, { + group: key, + text: `→ ${targetTitle}`, + sortKey: `\uFFFF${targetTitle}`, + }); + return key; + } + + /** A numeric axis dimension key (auto-registers its dimension descriptor). */ + numeric( + baseUrl: string, + title: string, + kind: "number" | "date" = "number", + ): string { + const dimension = `n\u0000${baseUrl}`; + this.#numericDimensions.set(dimension, { + group: `prop\u0000${baseUrl}`, + title, + kind, + sortKey: title, + }); + return dimension; + } + + addMember(spec: MemberSpec): EntityIdx { + this.#members.push(spec); + return EntityIdx(this.#members.length - 1); + } + + source(): FeatureSource { + const members = this.#members; + const descriptors = this.#descriptors; + const numericDimensions = this.#numericDimensions; + return { + keysOf: (member) => members[member]?.keys ?? [], + numericsOf: (member) => members[member]?.numerics ?? [], + describe: (key) => descriptors.get(key), + describeNumeric: (dimension) => numericDimensions.get(dimension), + }; + } +} + +function cluster(id: string, members: readonly EntityIdx[]): ClusterMembers { + return { childId: ClusterId(id), memberIdxs: Int32Array.from(members) }; +} + +describe("nameClustersByDistinctiveFeatures", () => { + it("names clusters by the exact value each shares but its sibling does not", () => { + const model = new FeatureModel(); + const foo = model.exact("dest", "Destination", "foo"); + const bar = model.exact("dest", "Destination", "bar"); + + const a = [0, 1, 2].map(() => + model.addMember({ keys: [foo], numerics: [] }), + ); + const b = [0, 1, 2].map(() => + model.addMember({ keys: [bar], numerics: [] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("A", a), cluster("B", b)], + model.source(), + ); + + expect(labels.get(ClusterId("A"))).toBe('Destination = "foo"'); + expect(labels.get(ClusterId("B"))).toBe('Destination = "bar"'); + }); + + it("leaves a cluster unnamed when its only feature is shared by every sibling", () => { + const model = new FeatureModel(); + const shared = model.exact("dest", "Destination", "foo"); + + const a = [0, 1, 2].map(() => + model.addMember({ keys: [shared], numerics: [] }), + ); + const b = [0, 1, 2].map(() => + model.addMember({ keys: [shared], numerics: [] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("A", a), cluster("B", b)], + model.source(), + ); + + expect(labels.size).toBe(0); + }); + + it("separates magnitude groups by numeric range when no exact value is common", () => { + const model = new FeatureModel(); + const quantity = model.numeric("qty", "Quantity"); + + const low = [10, 11, 12, 13, 14, 15, 16, 17].map((value) => + model.addMember({ keys: [], numerics: [{ dimension: quantity, value }] }), + ); + const high = [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007].map((value) => + model.addMember({ keys: [], numerics: [{ dimension: quantity, value }] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("Low", low), cluster("High", high)], + model.source(), + ); + + expect(labels.get(ClusterId("Low"))).toBe("Quantity 10–17"); + expect(labels.get(ClusterId("High"))).toMatch( + /^Quantity 1[,]?000–1[,]?007$/u, + ); + }); + + it("names clusters by what they link to (target type)", () => { + const model = new FeatureModel(); + const material = model.link("Material"); + const plant = model.link("Plant"); + + const a = [0, 1, 2].map(() => + model.addMember({ keys: [material], numerics: [] }), + ); + const b = [0, 1, 2].map(() => + model.addMember({ keys: [plant], numerics: [] }), + ); + + const labels = nameClustersByDistinctiveFeatures( + [cluster("A", a), cluster("B", b)], + model.source(), + ); + + expect(labels.get(ClusterId("A"))).toBe("→ Material"); + expect(labels.get(ClusterId("B"))).toBe("→ Plant"); + }); + + it("compounds a second feature, in deterministic order, to break a collision", () => { + const model = new FeatureModel(); + const us = model.exact("region", "Region", "US"); + const eu = model.exact("region", "Region", "EU"); + const asia = model.exact("region", "Region", "ASIA"); + const africa = model.exact("region", "Region", "AFRICA"); + const gold = model.exact("tier", "Tier", "gold"); + const silver = model.exact("tier", "Tier", "silver"); + + // A and B uniquely share Region = US (rare across the 5 siblings, so it is each one's TOP + // feature -> they collide); Tier is more widely shared, so it can only break the tie once + // compounded on top of Region. + const members = (keys: readonly string[]): EntityIdx[] => + [0, 1, 2].map(() => model.addMember({ keys, numerics: [] })); + const groupA = members([us, gold]); + const groupB = members([us, silver]); + const groupC = members([eu, gold, silver]); + const groupD = members([asia, gold]); + const groupE = members([africa, silver]); + + const labels = nameClustersByDistinctiveFeatures( + [ + cluster("A", groupA), + cluster("B", groupB), + cluster("C", groupC), + cluster("D", groupD), + cluster("E", groupE), + ], + model.source(), + ); + + // Region sorts before Tier, so the compound label is ordered deterministically + multi-line. + expect(labels.get(ClusterId("A"))).toBe('Region = "US"\nTier = "gold"'); + expect(labels.get(ClusterId("B"))).toBe('Region = "US"\nTier = "silver"'); + expect(labels.get(ClusterId("C"))).toBe('Region = "EU"'); + expect(labels.get(ClusterId("D"))).toBe('Region = "ASIA"'); + expect(labels.get(ClusterId("E"))).toBe('Region = "AFRICA"'); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts new file mode 100644 index 00000000000..90d44b7f9ab --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts @@ -0,0 +1,617 @@ +/** + * Name a set of sibling clusters by their distinctive shared FEATURES. + * + * Some upstream step (kmeans over embeddings, or the grouping fallback) has already grouped + * the entities; this finds a meaningful NAME for each group from the features its members + * share but its siblings don't. It is the same coverage x idf scoring the type labeler uses + * ({@link distinctiveLabel}), generalised over a UNIFIED feature space: + * + * - exact `(property = value)` pairs (e.g. `Destination = "foo"`), + * - numeric/date RANGES (e.g. `Quantity 100–500`), bucketed per-subdivision from the live + * distribution of the siblings (so a group split off by magnitude gets a range name even + * though no single exact value is common to it), and + * - LINK target types (e.g. `→ Material`) -- what a group's members link TO, the only signal + * that distinguishes entities whose own properties are uniform (e.g. batches). + * + * For every feature: + * - coverage = fraction of a cluster's members that carry it (it must be COMMON within the + * group), and + * - idf = inverse document frequency across the SIBLING clusters (it must be RARE across + * them -- "depending on its pre-clustering"). + * + * So a feature every group shares scores ~0 and is ignored, while one characteristic of a + * single group wins. Features are grouped by a dedup GROUP key (all values/ranges of one + * property, and each link target, form a group) so a compound label never repeats a + * dimension and an exact value and its range can't both appear. + * + * Collisions are broken in two stages. First, two groups landing on the same label are each + * extended with their next-most-distinctive CHARACTERISTIC feature. When that is exhausted + * because the groups genuinely share every >= MIN_COVERAGE feature, they are separated on the + * feature where they MOST differ -- searched across ALL coverages, not just the dominant ones. + * Only two truly indistinguishable groups stay sharing a name. + * + * The scan is bounded: a cluster with more than {@link MAX_SAMPLE_MEMBERS} members is named + * from a deterministic, evenly-spread SAMPLE of that many, so naming cost stays flat on huge + * clusters. The chosen parts render in a deterministic order and join onto separate lines, so + * a multi-part label reads as a stable little table inside the bubble and never reorders. + */ +import type { ClusterId, EntityIdx } from "../../ids"; + +export interface ClusterMembers { + readonly childId: ClusterId; + readonly memberIdxs: Int32Array; +} + +/** What a feature renders to in a label, plus the group it dedups within and its sort key. */ +export interface FeatureDescriptor { + /** Dedup group: at most one part per group appears in a compound label. */ + readonly group: string; + /** The fully-rendered label line, e.g. `Destination = "foo"`, `Quantity 100–500`, `→ Material`. */ + readonly text: string; + /** Stable key the parts of a label sort by (so a label never reshuffles its lines). */ + readonly sortKey: string; +} + +/** A raw numeric reading of one member, to be bucketed into a range per-subdivision. */ +export interface NumericReading { + /** Stable per-property axis key; range buckets are computed per dimension. */ + readonly dimension: string; + /** A plain number, or a date as epoch milliseconds. */ + readonly value: number; +} + +/** Title + kind of a numeric axis, for rendering its range buckets. */ +export interface NumericDimension { + /** Dedup group, shared with the property's exact features so a property yields one part. */ + readonly group: string; + readonly title: string; + readonly kind: "number" | "date"; + readonly sortKey: string; +} + +/** + * Supplies per-member features for naming. Implemented in the worker over its stores + * ({@link createClusterFeatureSource}); mocked directly in tests. The namer treats every + * key as opaque except numeric readings, whose range bucketing it owns. + */ +export interface FeatureSource { + /** Stable feature keys for a member (exact property + link/target-type). */ + keysOf(member: EntityIdx): Iterable; + /** Raw numeric/date readings for a member, for per-subdivision range bucketing. */ + numericsOf(member: EntityIdx): Iterable; + /** Describe a key returned by {@link keysOf}, or undefined to ignore it. */ + describe(key: string): FeatureDescriptor | undefined; + /** Describe a numeric dimension (title + kind), or undefined to ignore it. */ + describeNumeric(dimension: string): NumericDimension | undefined; +} + +/** A feature must cover at least this fraction of a cluster to be a labelling candidate. */ +const MIN_COVERAGE = 0.6; +/** Most parts joined into one compound label (collision breaking). */ +const MAX_LABEL_PARTS = 3; +/** + * A discriminative tie-break feature need only be reasonably common in its cluster (the + * groups already share their dominant features, so the separator lives below MIN_COVERAGE). + */ +const DISCRIMINATOR_MIN_COVERAGE = 0.34; +/** ...and it must actually separate: this much more prevalent here than in the colliding peers. */ +const DISCRIMINATOR_MIN_GAP = 0.2; +/** Discriminative passes to attempt once characteristic compounding is exhausted. */ +const MAX_DISCRIMINATOR_PASSES = 2; + +/** Cap the members scanned per cluster; bigger clusters name from an even sample of this many. */ +const MAX_SAMPLE_MEMBERS = 5000; +/** + * A numeric axis needs at least this many DISTINCT values across the subdivision to be range + * bucketed; below it the property is low-cardinality and its exact value features name it. + */ +const MIN_NUMERIC_DISTINCT = 8; + +/** Per-subdivision range buckets for one numeric axis. */ +interface NumericRange { + readonly describe: NumericDimension; + /** Strictly-increasing interior edges; bucket b spans [edges[b-1], edges[b]). */ + readonly edges: number[]; + /** Observed [min, max] of the values that fall in each bucket (parallel to edges + 1). */ + readonly bounds: { min: number; max: number }[]; +} + +interface Candidate extends FeatureDescriptor { + readonly coverage: number; + readonly score: number; +} + +/** + * A deterministic, evenly-spread sample of at most `max` members. Returns the input + * unchanged when it already fits, so small clusters are scanned in full. + */ +function subsample(members: Int32Array, max: number): Int32Array { + if (members.length <= max) { + return members; + } + const sampled = new Int32Array(max); + for (let index = 0; index < max; index++) { + sampled[index] = members[Math.floor((index * members.length) / max)]!; + } + return sampled; +} + +/** Median of a non-empty ascending-sorted array. */ +function median(sorted: number[]): number { + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[mid]! + : (sorted[mid - 1]! + sorted[mid]!) / 2; +} + +/** Bucket index (0..edges.length) of a value against interior edges. */ +function bucketOf(value: number, edges: number[]): number { + let bucket = 0; + while (bucket < edges.length && value >= edges[bucket]!) { + bucket++; + } + return bucket; +} + +/** Format a number compactly: integers plain, fractions to a few significant digits. */ +function formatNumber(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString("en-US"); + } + const magnitude = Math.abs(value); + const fractionDigits = magnitude >= 100 ? 0 : magnitude >= 1 ? 2 : 4; + return value.toLocaleString("en-US", { + maximumFractionDigits: fractionDigits, + }); +} + +/** Format a range bound according to its axis kind (a number, or a `YYYY-MM-DD` date). */ +function formatBound(value: number, kind: "number" | "date"): string { + if (kind === "date") { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? formatNumber(value) + : date.toISOString().slice(0, 10); + } + return formatNumber(value); +} + +/** The descriptor for a value falling in one bucket of a numeric range. */ +function rangeDescriptor( + range: NumericRange, + bucket: number, +): FeatureDescriptor { + const { bounds, describe } = range; + const { min, max } = bounds[bucket] ?? { min: NaN, max: NaN }; + let text: string; + if (!Number.isFinite(min) || !Number.isFinite(max)) { + text = describe.title; + } else if (min === max) { + text = `${describe.title} ${formatBound(min, describe.kind)}`; + } else { + text = `${describe.title} ${formatBound(min, describe.kind)}–${formatBound( + max, + describe.kind, + )}`; + } + return { + group: describe.group, + text, + sortKey: `${describe.sortKey}\u0000${bucket}`, + }; +} + +/** + * Per-axis range buckets for the whole subdivision. Bucket edges sit at the MIDPOINTS between + * the clusters' median values (not at global quantiles): a group split off by magnitude + * occupies a contiguous band, so a midpoint between its median and its neighbour's keeps that + * whole band in ONE bucket -- where equal-frequency quantiles would slice the band in two and + * leave its coverage below MIN_COVERAGE. Only high-cardinality axes are bucketed (low-cardinality + * ones name fine by exact value), and only those with two or more distinct cluster medians (no + * contrast, no point). + */ +function buildNumericRanges( + samples: readonly Int32Array[], + source: FeatureSource, +): Map { + // axis -> per-cluster list of that axis's values among the cluster's sampled members. + const valuesByAxis = new Map(); + for (let cluster = 0; cluster < samples.length; cluster++) { + for (const member of samples[cluster]!) { + for (const reading of source.numericsOf(member as EntityIdx)) { + let perCluster = valuesByAxis.get(reading.dimension); + if (!perCluster) { + perCluster = samples.map(() => []); + valuesByAxis.set(reading.dimension, perCluster); + } + perCluster[cluster]!.push(reading.value); + } + } + } + + const ranges = new Map(); + for (const [dimension, perCluster] of valuesByAxis) { + const all = perCluster.flat().sort((left, right) => left - right); + let distinct = all.length > 0 ? 1 : 0; + for (let index = 1; index < all.length; index++) { + if (all[index] !== all[index - 1]) { + distinct++; + } + } + if (distinct < MIN_NUMERIC_DISTINCT) { + continue; + } + + // One representative median per cluster the axis is CHARACTERISTIC of (covers a majority). + const medians: number[] = []; + for (let cluster = 0; cluster < perCluster.length; cluster++) { + const values = perCluster[cluster]!; + if (values.length / samples[cluster]!.length < MIN_COVERAGE) { + continue; + } + medians.push(median([...values].sort((left, right) => left - right))); + } + const distinctMedians = [...new Set(medians)].sort( + (left, right) => left - right, + ); + if (distinctMedians.length < 2) { + continue; + } + + const describe = source.describeNumeric(dimension); + if (!describe) { + continue; + } + + const edges: number[] = []; + for (let index = 1; index < distinctMedians.length; index++) { + edges.push((distinctMedians[index - 1]! + distinctMedians[index]!) / 2); + } + + const bounds = Array.from({ length: edges.length + 1 }, () => ({ + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + })); + for (const value of all) { + const bound = bounds[bucketOf(value, edges)]!; + bound.min = Math.min(bound.min, value); + bound.max = Math.max(bound.max, value); + } + + ranges.set(dimension, { describe, edges, bounds }); + } + return ranges; +} + +/** + * Coverage of every feature in one cluster (fraction of sampled members carrying it), + * registering each feature's descriptor as it is first seen. A member counts a feature once, + * however many of its links/values produce it. + */ +function clusterCoverage( + sample: Int32Array, + source: FeatureSource, + ranges: Map, + descriptors: Map, +): Map { + const counts = new Map(); + const size = sample.length; + if (size === 0) { + return counts; + } + + for (const member of sample) { + const seen = new Set(); + for (const key of source.keysOf(member as EntityIdx)) { + if (seen.has(key)) { + continue; + } + let descriptor = descriptors.get(key); + if (!descriptor) { + descriptor = source.describe(key); + if (!descriptor) { + continue; + } + descriptors.set(key, descriptor); + } + seen.add(key); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + for (const reading of source.numericsOf(member as EntityIdx)) { + const range = ranges.get(reading.dimension); + if (!range) { + continue; + } + const bucket = bucketOf(reading.value, range.edges); + const key = `range\u0000${reading.dimension}\u0000${bucket}`; + if (seen.has(key)) { + continue; + } + if (!descriptors.has(key)) { + descriptors.set(key, rangeDescriptor(range, bucket)); + } + seen.add(key); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + + for (const [key, count] of counts) { + counts.set(key, count / size); + } + return counts; +} + +/** + * Rank a cluster's distinctive candidates: keep only the best-scoring feature PER GROUP (so a + * compound label never repeats a property and reaches for a different one), distinctive first. + */ +function rankCandidates( + coverage: Map, + documentFrequency: Map, + clusterCount: number, + descriptors: Map, +): Candidate[] { + const bestPerGroup = new Map(); + + for (const [key, fraction] of coverage) { + if (fraction < MIN_COVERAGE) { + continue; + } + const descriptor = descriptors.get(key); + if (!descriptor) { + continue; + } + const docFreq = documentFrequency.get(key) ?? 1; + const idf = Math.log((clusterCount + 1) / (docFreq + 1)); + const score = fraction * idf; + if (score <= 0) { + // Shared by every cluster (idf 0) -- carries no distinguishing signal. + continue; + } + + const candidate: Candidate = { + group: descriptor.group, + text: descriptor.text, + sortKey: descriptor.sortKey, + coverage: fraction, + score, + }; + const existing = bestPerGroup.get(descriptor.group); + // Deterministic best-feature selection: higher score, then higher coverage, then sort key. + if ( + !existing || + score > existing.score || + (score === existing.score && + (fraction > existing.coverage || + (fraction === existing.coverage && + descriptor.sortKey.localeCompare(existing.sortKey) < 0))) + ) { + bestPerGroup.set(descriptor.group, candidate); + } + } + + return [...bestPerGroup.values()].sort( + (left, right) => + right.score - left.score || + right.coverage - left.coverage || + left.sortKey.localeCompare(right.sortKey) || + left.text.localeCompare(right.text), + ); +} + +/** + * The feature most over-represented in `here` versus the colliding `peers`, drawn from ALL + * of this cluster's features (not just the >= MIN_COVERAGE ones), excluding groups already in + * the label. Undefined when nothing separates them well enough. + */ +function bestDiscriminator( + here: Map, + peers: readonly number[], + coverages: readonly Map[], + used: ReadonlySet, + descriptors: Map, +): FeatureDescriptor | undefined { + const qualifying: { + part: FeatureDescriptor; + gap: number; + coverage: number; + }[] = []; + + for (const [key, coverage] of here) { + if (coverage < DISCRIMINATOR_MIN_COVERAGE) { + continue; + } + const descriptor = descriptors.get(key); + if (!descriptor || used.has(descriptor.group)) { + continue; + } + let peerMax = 0; + for (const peer of peers) { + const peerCoverage = coverages[peer]!.get(key) ?? 0; + if (peerCoverage > peerMax) { + peerMax = peerCoverage; + } + } + const gap = coverage - peerMax; + if (gap < DISCRIMINATOR_MIN_GAP) { + continue; + } + qualifying.push({ part: descriptor, gap, coverage }); + } + + qualifying.sort( + (left, right) => + right.gap - left.gap || + right.coverage - left.coverage || + left.part.sortKey.localeCompare(right.part.sortKey) || + left.part.text.localeCompare(right.part.text), + ); + return qualifying[0]?.part; +} + +/** + * Render a label's parts to its display string: parts ordered deterministically by sort key + * (then text) and placed one per line. The stable order means the same SET of parts always + * renders to the same string, so collision detection is exact. + */ +function renderLabel(parts: readonly FeatureDescriptor[]): string { + return [...parts] + .sort( + (left, right) => + left.sortKey.localeCompare(right.sortKey) || + left.text.localeCompare(right.text), + ) + .map((part) => part.text) + .join("\n"); +} + +/** + * Turn ranked candidates into labels, extending colliding clusters until their labels + * separate (characteristic compounding first, then a discriminative tie-break) or the + * label-part budget is spent. + */ +function resolveLabels( + clusters: readonly ClusterMembers[], + candidates: readonly Candidate[][], + coverages: readonly Map[], + descriptors: Map, +): Map { + const chosen: FeatureDescriptor[][] = candidates.map((list) => + list.length > 0 ? [list[0]!] : [], + ); + const used: Set[] = chosen.map( + (parts) => new Set(parts.map((part) => part.group)), + ); + + // Indices of named clusters grouped by identical rendered label (size > 1 = a collision). + const collisions = (): number[][] => { + const byLabel = new Map(); + for (let index = 0; index < clusters.length; index++) { + if (chosen[index]!.length === 0) { + continue; + } + const key = renderLabel(chosen[index]!); + const bucket = byLabel.get(key); + if (bucket) { + bucket.push(index); + } else { + byLabel.set(key, [index]); + } + } + return [...byLabel.values()].filter((bucket) => bucket.length > 1); + }; + + // Phase 1: lengthen colliding clusters with their next CHARACTERISTIC candidate. + for (let pass = 1; pass < MAX_LABEL_PARTS; pass++) { + const groups = collisions(); + if (groups.length === 0) { + break; + } + let extended = false; + for (const group of groups) { + for (const index of group) { + if (chosen[index]!.length >= MAX_LABEL_PARTS) { + continue; + } + const next = candidates[index]!.find( + (candidate) => !used[index]!.has(candidate.group), + ); + if (next) { + chosen[index]!.push(next); + used[index]!.add(next.group); + extended = true; + } + } + } + if (!extended) { + break; + } + } + + // Phase 2: clusters that still collide share every characteristic feature -- separate them + // on the feature where they MOST differ. Genuinely identical groups find nothing and stay. + for (let pass = 0; pass < MAX_DISCRIMINATOR_PASSES; pass++) { + const groups = collisions(); + if (groups.length === 0) { + break; + } + let separated = false; + for (const group of groups) { + for (const index of group) { + if (chosen[index]!.length >= MAX_LABEL_PARTS) { + continue; + } + const peers = group.filter((other) => other !== index); + const part = bestDiscriminator( + coverages[index]!, + peers, + coverages, + used[index]!, + descriptors, + ); + if (part) { + chosen[index]!.push(part); + used[index]!.add(part.group); + separated = true; + } + } + } + if (!separated) { + break; + } + } + + const labels = new Map(); + for (let index = 0; index < clusters.length; index++) { + if (chosen[index]!.length === 0) { + continue; + } + const label = renderLabel(chosen[index]!); + if (label.length > 0) { + labels.set(clusters[index]!.childId, label); + } + } + return labels; +} + +/** + * Distinctive label per cluster. Only clusters with a confident, distinctive signature appear + * in the result; the rest keep their placeholder. + */ +export function nameClustersByDistinctiveFeatures( + clusters: readonly ClusterMembers[], + source: FeatureSource, +): Map { + const clusterCount = clusters.length; + + // Bound the scan: name big clusters from an even sample so cost stays flat. + const samples = clusters.map((cluster) => + subsample(cluster.memberIdxs, MAX_SAMPLE_MEMBERS), + ); + + // Numeric/date axes are bucketed from the whole subdivision's distribution first. + const ranges = buildNumericRanges(samples, source); + + // Per-cluster feature coverage (fraction of members sharing each feature), building the + // shared descriptor table as features are encountered. + const descriptors = new Map(); + const coverages = samples.map((sample) => + clusterCoverage(sample, source, ranges, descriptors), + ); + + // Document frequency: how many clusters a feature is CHARACTERISTIC of (>= MIN_COVERAGE). + const documentFrequency = new Map(); + for (const coverage of coverages) { + for (const [key, fraction] of coverage) { + if (fraction >= MIN_COVERAGE) { + documentFrequency.set(key, (documentFrequency.get(key) ?? 0) + 1); + } + } + } + + const candidates = coverages.map((coverage) => + rankCandidates(coverage, documentFrequency, clusterCount, descriptors), + ); + + return resolveLabels(clusters, candidates, coverages, descriptors); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts new file mode 100644 index 00000000000..e2a2f02b2e8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts @@ -0,0 +1,264 @@ +/** + * Level-of-detail (LOD) decisions driven by viewport state. + * + * The visible cut is a set of cluster tree nodes to render. + * Each node in the cut has a mode: render as bubble, show children, + * or show individual entities. Hysteresis prevents flickering + * at threshold boundaries. + * + * Transitions (spec 4.4) are not yet implemented: LOD changes + * snap immediately rather than animating. Needs Deck.gl transition + * support or manual interpolation on the main thread. + */ +import { Bbox, screenRadius } from "../../geometry"; + +import type { VizConfig } from "../../config"; +import type { ClusterId, LodMode } from "../../ids"; +import type { ClusterNode, ClusterTree } from "./cluster-tree"; + +export interface ViewportState { + readonly zoom: number; + readonly centerX: number; + readonly centerY: number; + readonly width: number; + readonly height: number; +} + +export interface LodItem { + readonly clusterId: ClusterId; + readonly mode: LodMode; +} + +function screenRadiusPx(cluster: ClusterNode, zoom: number): number { + return screenRadius(cluster.circle, zoom); +} + +function setsEqual(lhs: Set, rhs: Set): boolean { + if (lhs.size !== rhs.size) { + return false; + } + for (const item of lhs) { + if (!rhs.has(item)) { + return false; + } + } + return true; +} + +/** + * Tracks the previous LOD decisions for hysteresis. + * Open/close thresholds differ so clusters don't flicker at boundaries. + */ +export class LodState { + readonly #visibleIds = new Set(); + readonly #showingChildren = new Set(); + readonly #showingEntities = new Set(); + + wasShowingChildren(clusterId: ClusterId): boolean { + return this.#showingChildren.has(clusterId); + } + + wasShowingEntities(clusterId: ClusterId): boolean { + return this.#showingEntities.has(clusterId); + } + + #partition(items: readonly LodItem[]): { + readonly visible: Set; + readonly children: Set; + readonly entities: Set; + } { + const visible = new Set(); + const children = new Set(); + const entities = new Set(); + + for (const item of items) { + visible.add(item.clusterId); + if (item.mode === "children") { + children.add(item.clusterId); + } else if (item.mode === "entities" || item.mode === "entities-pending") { + entities.add(item.clusterId); + } + } + + return { visible, children, entities }; + } + + /** + * Non-destructive probe: would applying this cut change the committed + * open-state? Lets a caller decide whether a frame is needed without + * mutating hysteresis state. + */ + wouldChange(items: readonly LodItem[]): boolean { + const { visible, children, entities } = this.#partition(items); + return ( + !setsEqual(visible, this.#visibleIds) || + !setsEqual(children, this.#showingChildren) || + !setsEqual(entities, this.#showingEntities) + ); + } + + /** + * Commit a new visible cut. Returns true if the cut changed. + * This is the single point where open-state is committed; call it + * alongside force-layout creation/destruction so they never diverge. + */ + applyVisibleCut(items: readonly LodItem[]): boolean { + const { visible, children, entities } = this.#partition(items); + + const changed = + !setsEqual(visible, this.#visibleIds) || + !setsEqual(children, this.#showingChildren) || + !setsEqual(entities, this.#showingEntities); + + this.#visibleIds.clear(); + for (const id of visible) { + this.#visibleIds.add(id); + } + + this.#showingChildren.clear(); + for (const id of children) { + this.#showingChildren.add(id); + } + + this.#showingEntities.clear(); + for (const id of entities) { + this.#showingEntities.add(id); + } + + return changed; + } +} + +/** + * Compute the visible cut: which clusters to render and in what mode. + * + * Walks the cluster tree top-down, using screen-space radius to decide + * whether to open each cluster. Uses a priority queue (largest screen + * radius first) and respects render budgets. + */ +export function computeVisibleCut( + tree: ClusterTree, + rootId: ClusterId, + viewport: ViewportState, + lodState: LodState, + config: VizConfig, + trySubdivide?: (node: ClusterNode) => boolean, + /** Clusters forced open regardless of zoom/viewport/budget (a pinned selection's leaf + + * ancestors). Ancestors open to children; the pinned leaf opens to entities. */ + pinnedOpen?: ReadonlySet, +): LodItem[] { + const root = tree.get(rootId); + if (!root) { + return []; + } + + const result: LodItem[] = []; + const viewBbox = Bbox.fromViewport( + viewport.centerX, + viewport.centerY, + viewport.width, + viewport.height, + viewport.zoom, + ); + + // Priority queue sorted by screen radius (largest first). + // Simple array + sort since cluster counts are bounded by maxRenderedClusters. + const queue: ClusterNode[] = []; + + for (const child of root.children) { + queue.push(child); + } + + queue.sort( + (lhs, rhs) => + screenRadiusPx(rhs, viewport.zoom) - screenRadiusPx(lhs, viewport.zoom), + ); + + let renderedClusters = 0; + let renderedEntities = 0; + + while (queue.length > 0) { + const node = queue.shift()!; + + // The cut is viewport-independent for inclusion: an off-screen cluster is + // not dropped. Frustum culling is Deck.gl's job at render time; dropping a + // panned-off cluster from the cut used to make it vanish from the obstacle + // list, so edges suddenly re-routed "as if it never existed", and it churned + // a re-commit on every pan. Whether a cluster opens is still viewport-gated + // (centerInView, with hysteresis so it doesn't snap shut when panned out). + + const rPx = screenRadiusPx(node, viewport.zoom); + let hasChildren = node.children.length > 0; + const viewMin = Math.min(viewport.width, viewport.height); + + // Only open clusters whose center is inside the viewport. + // Already-open clusters stay open using the close threshold + // even if panned partially off-screen. + const centerInView = viewBbox.containsPoint(node.circle.x, node.circle.y); + + // Hysteresis: thresholds as fraction of viewport min dimension. + const wasOpen = lodState.wasShowingChildren(node.id); + const openChildren = wasOpen + ? rPx >= config.closeChildrenFraction * viewMin + : centerInView && rPx >= config.openChildrenFraction * viewMin; + + const wasShowingEntities = lodState.wasShowingEntities(node.id); + const openEntities = wasShowingEntities + ? rPx >= config.closeEntitiesFraction * viewMin + : centerInView && rPx >= config.openEntitiesFraction * viewMin; + + // A pinned cluster (the selected node's leaf + ancestors) opens regardless of zoom, + // viewport, or budget -- it stays open until deselected (the birds-eye view). + const pinned = pinnedOpen?.has(node.id) ?? false; + + // Leaf cluster small enough to show entities? + if ( + !hasChildren && + node.count <= config.entityRevealMax && + (pinned || + (openEntities && + renderedEntities + node.count <= config.maxRenderedEntities)) + ) { + result.push({ clusterId: node.id, mode: "entities" }); + renderedEntities += node.count; + continue; + } + + // Too large for entities and no children: try lazy subdivision. + // Only attempt when screen space warrants opening. + if (!hasChildren && (openChildren || pinned) && trySubdivide?.(node)) { + hasChildren = node.children.length > 0; + } + + // Has children and big enough to open? + if ( + hasChildren && + (pinned || + (openChildren && + renderedClusters + node.children.length <= + config.maxRenderedClusters)) + ) { + result.push({ clusterId: node.id, mode: "children" }); + + // Add children to the queue, maintaining sort order. + for (const child of node.children) { + const childRPx = screenRadiusPx(child, viewport.zoom); + let insertIdx = 0; + while ( + insertIdx < queue.length && + screenRadiusPx(queue[insertIdx]!, viewport.zoom) > childRPx + ) { + insertIdx++; + } + queue.splice(insertIdx, 0, child); + } + continue; + } + + // Default: render as a single bubble. + result.push({ clusterId: node.id, mode: "cluster" }); + renderedClusters++; + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/cluster-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/cluster-layout.ts new file mode 100644 index 00000000000..3d2a89ac4e1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/cluster-layout.ts @@ -0,0 +1,392 @@ +/** + * Cluster (macro) layout, powered by WebCola. + * + * WebCola solves the layout we actually want directly: stress majorisation + * embeds the link structure (connected clusters end up near each other, the + * "edges have meaning" principle that motivated a structural layout over circle + * packing), while gradient-projection non-overlap constraints guarantee the + * bubbles never intersect. There is no force seed and no anchor: the positions + * are the optimum of that objective, not a polish of some local minimum. A + * crossing-reduction pass ({@link "./untangle"}) then runs over the settled + * result to remove edge crossings WebCola's stress model does not target. + * + * The engine is driven manually (one majorisation step per `tick`, within a + * time budget) so settling streams to the GPU live and never blocks the + * worker. Positions are written to a SharedArrayBuffer, re-centred on the origin + * each step (the parent translates them to world coords). + * + * Spacing is size-aware: link lengths and non-overlap padding scale with the + * endpoints' radii, because top-level bubbles vary enormously in size (a 5k-node + * type next to a 20-node one) and a single ideal length would pack the big ones + * to touching. The root layout spreads generously; subcluster layouts stay snug + * so children fill their parent circle. + * + * Confinement: WebCola lays out unconstrained and we re-centre; the parent + * circle is sized by the cluster tree's packing, close to WebCola's non-overlap + * extent. (Hard circular confinement, and ports as fixed boundary nodes, is a + * planned refinement; WebCola's `fixed` node locking is the hook.) + */ +import { Layout } from "webcola"; + +import { PositionBuffer } from "../buffers/position-buffer"; + +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, + PortAnchor, +} from "./force-simulation"; +import type { InputNode, Link } from "webcola"; + +/** Non-overlap padding around each bubble, as a fraction of the mean radius. */ +const ROOT_PAD_MUL = 0.35; +const SUB_PAD_MUL = 0.05; +/** Ideal link length as a multiple of the linked pair's combined radii. */ +const ROOT_SEP_MUL = 1.7; +const SUB_SEP_MUL = 1.2; +/** Safety cap on majorisation steps so a layout always settles. */ +const MAX_STEPS = 2000; +/** WebCola alpha that kicks the descent into running after initialisation. */ +const START_ALPHA = 0.1; +/** Overlap-relaxation passes when fitting a confined sub-cluster to its circle. */ +const CONFINE_PASSES = 16; +/** Child->port-anchor link length, as a fraction of the parent radius. */ +const ANCHOR_LINK_FRAC = 0.6; + +/** Subclass exposing WebCola's protected `tick` so we can drive it ourselves. */ +class SteppableLayout extends Layout { + /** One stress-majorisation step. Returns true once converged. */ + runStep(): boolean { + return this.tick(); + } +} + +class ClusterLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + /** Children (0..n-1) plus any fixed port anchors appended by setPortAnchors. */ + #colaNodes: InputNode[]; + /** The children-only cola nodes (rebuilt from on each setPortAnchors). */ + readonly #childColaNodes: InputNode[]; + /** The inter-sibling links (anchor links are appended on setPortAnchors). */ + readonly #childLinks: Link[]; + readonly #cola: SteppableLayout; + readonly #buffer: PositionBuffer; + readonly #confinementRadius: number | undefined; + /** Scratch for the re-centred (and, if confined, fitted) local positions. */ + readonly #posX: Float64Array; + readonly #posY: Float64Array; + #status: ForceLayoutStatus; + #steps = 0; + + constructor( + nodes: ForceNode[], + edges: ForceEdge[], + confinementRadius?: number, + ) { + this.#nodes = nodes; + this.#buffer = new PositionBuffer(nodes.length); + this.#confinementRadius = confinementRadius; + this.#posX = new Float64Array(nodes.length); + this.#posY = new Float64Array(nodes.length); + const isRoot = confinementRadius === undefined; + + let meanRadius = 0; + for (const node of nodes) { + meanRadius += node.radius; + } + meanRadius = nodes.length > 0 ? meanRadius / nodes.length : 1; + + const pad = (isRoot ? ROOT_PAD_MUL : SUB_PAD_MUL) * meanRadius; + const sepMul = isRoot ? ROOT_SEP_MUL : SUB_SEP_MUL; + + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + + // WebCola nodes: seed position + padded bounding box for non-overlap. + this.#colaNodes = nodes.map((node) => ({ + x: node.x ?? 0, + y: node.y ?? 0, + width: (node.radius + pad) * 2, + height: (node.radius + pad) * 2, + })); + this.#childColaNodes = this.#colaNodes; + + // Resolve edges to index links with size-aware ideal lengths. We never + // mutate the input edges (d3's forceLink rewrote source/target, a footgun). + const links: Link[] = []; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const sourceIndex = idToIndex.get(sourceId); + const targetIndex = idToIndex.get(targetId); + if ( + sourceIndex !== undefined && + targetIndex !== undefined && + sourceIndex !== targetIndex + ) { + const ideal = + (nodes[sourceIndex]!.radius + nodes[targetIndex]!.radius) * sepMul; + links.push({ source: sourceIndex, target: targetIndex, length: ideal }); + } + } + this.#childLinks = links; + + this.#cola = new SteppableLayout(); + this.#cola + .nodes(this.#colaNodes) + .links(links) + .avoidOverlaps(true) + .handleDisconnected(true); + // Build the descent + distance matrix without iterating, then kick alpha so + // our manual `tick`s drive the majorisation. + this.#cola.start(0, 0, 0, 0, false, false); + this.#cola.alpha(START_ALPHA); + + this.#status = "running"; + this.#writePositions(); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#cola.alpha(); + } + + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let changed = false; + let converged = false; + + while (performance.now() - startTime < budgetMs && !converged) { + converged = this.#cola.runStep(); + this.#steps += 1; + changed = true; + if (this.#steps >= MAX_STEPS) { + converged = true; + } + } + + if (changed) { + this.#writePositions(); + } + if (converged) { + this.#status = "settled"; + } + return changed; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + this.#cola.alpha(START_ALPHA); + } + } + + /** + * Re-run the layout with fixed external-port anchors (see {@link PortAnchor}). + * Each anchor is a pinned WebCola node on the rim; the children linked to it + * are pulled toward their external connection, so they sort to the correct + * side and feeders leave the container without crossing. Anchors are appended + * after the children, so the SharedArrayBuffer output (children only, 0..n-1) + * is unaffected. + */ + setPortAnchors(anchors: readonly PortAnchor[]): void { + if (anchors.length === 0) { + return; + } + const childCount = this.#childColaNodes.length; + const anchorNodes: InputNode[] = anchors.map((anchor) => ({ + x: anchor.x, + y: anchor.y, + fixed: 1, + width: 1, + height: 1, + })); + this.#colaNodes = [...this.#childColaNodes, ...anchorNodes]; + + const links: Link[] = [...this.#childLinks]; + const anchorLength = (this.#confinementRadius ?? 1) * ANCHOR_LINK_FRAC; + for (const [anchorOffset, anchor] of anchors.entries()) { + const anchorIndex = childCount + anchorOffset; + for (const child of anchor.children) { + links.push({ + source: child.index, + target: anchorIndex, + length: anchorLength, + weight: child.weight, + }); + } + } + + this.#cola + .nodes(this.#colaNodes) + .links(links) + // Disable disconnected-component packing for the re-run: it can relocate + // our fixed rim anchors. The anchors + sibling links provide connectivity. + .handleDisconnected(false) + .start(0, 0, 0, 0, false, false); + this.#cola.alpha(START_ALPHA); + this.#status = "running"; + this.#steps = 0; + this.#writePositions(); + } + + /** + * Move the fixed port anchors in place: no re-run, no emit. WebCola re-reads + * a fixed node's locked `px`/`py` each step, so updating them re-aims the + * anchors; a still-running layout's children follow as it settles. (A layout + * that has already settled won't move, which is acceptable: the macro and its + * sub-clusters co-settle, which is when tracking matters.) + */ + updateAnchorPositions( + positions: readonly { readonly x: number; readonly y: number }[], + ): void { + const childCount = this.#childColaNodes.length; + for (let idx = 0; idx < positions.length; idx++) { + const anchor = this.#colaNodes[childCount + idx] as + | (InputNode & { px?: number; py?: number }) + | undefined; + if (!anchor) { + continue; + } + const target = positions[idx]!; + anchor.x = target.x; + anchor.px = target.x; + anchor.y = target.y; + anchor.py = target.y; + } + } + + /** Sync positions from WebCola, re-centred on the origin, into the shared buffer. */ + #writePositions(): void { + const count = this.#nodes.length; + let centroidX = 0; + let centroidY = 0; + for (let idx = 0; idx < count; idx++) { + centroidX += this.#colaNodes[idx]!.x ?? 0; + centroidY += this.#colaNodes[idx]!.y ?? 0; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + const xs = this.#posX; + const ys = this.#posY; + for (let idx = 0; idx < count; idx++) { + xs[idx] = (this.#colaNodes[idx]!.x ?? 0) - centroidX; + ys[idx] = (this.#colaNodes[idx]!.y ?? 0) - centroidY; + } + + // Sub-cluster layouts are confined to the parent circle. WebCola lays out + // unconstrained: its stress spread is wider than the tree's circle-packing, + // so without this the children stick out of their parent bubble. Clamp into + // the circle and relax overlaps; a contained, ~non-overlapping fit exists + // (the tree packed them), and the relaxation recovers it. + if (this.#confinementRadius !== undefined) { + this.#fitWithin(this.#confinementRadius); + } + + const positions = this.#buffer.positions; + for (let idx = 0; idx < count; idx++) { + // Mirror into the ForceNode view so #writeChildCircles / untangle read it. + this.#nodes[idx]!.x = xs[idx]!; + this.#nodes[idx]!.y = ys[idx]!; + positions[idx * 2] = xs[idx]!; + positions[idx * 2 + 1] = ys[idx]!; + } + this.#buffer.commit(); + } + + /** Clamp positions inside the confinement circle and relax overlaps, in place. */ + #fitWithin(radius: number): void { + const count = this.#nodes.length; + const xs = this.#posX; + const ys = this.#posY; + const clamp = (idx: number): void => { + const limit = Math.max(0, radius - this.#nodes[idx]!.radius); + const dist = Math.hypot(xs[idx]!, ys[idx]!); + if (dist > limit && dist > 0) { + const scale = limit / dist; + xs[idx] = xs[idx]! * scale; + ys[idx] = ys[idx]! * scale; + } + }; + + for (let idx = 0; idx < count; idx++) { + clamp(idx); + } + for (let pass = 0; pass < CONFINE_PASSES; pass++) { + let moved = false; + for (let first = 0; first < count; first++) { + for (let second = first + 1; second < count; second++) { + let dx = xs[second]! - xs[first]!; + let dy = ys[second]! - ys[first]!; + let dist = Math.hypot(dx, dy); + const minDist = + this.#nodes[first]!.radius + this.#nodes[second]!.radius; + if (dist < minDist) { + if (dist < 1e-6) { + dx = 1; + dy = 0; + dist = 1; + } + const push = (minDist - dist) / 2; + const ux = (dx / dist) * push; + const uy = (dy / dist) * push; + xs[first] = xs[first]! - ux; + ys[first] = ys[first]! - uy; + xs[second] = xs[second]! + ux; + ys[second] = ys[second]! + uy; + moved = true; + } + } + } + for (let idx = 0; idx < count; idx++) { + clamp(idx); + } + if (!moved) { + break; + } + } + } +} + +export function createClusterLayout( + nodes: ForceNode[], + edges: ForceEdge[], + confinementRadius?: number, +): LayoutSimulation { + return new ClusterLayout(nodes, edges, confinementRadius); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts new file mode 100644 index 00000000000..1efc21576dc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts @@ -0,0 +1,258 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + FlatGraphBuffer, +} from "../buffers/position-buffer"; +import { createCommunityLayout } from "./community-layout"; + +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +function settle(layout: LayoutSimulation): void { + for (let step = 0; step < 4000 && !layout.isSettled; step++) { + layout.tick(50); + } +} + +function positionsOf(layout: LayoutSimulation): [number, number][] { + return layout.nodes.map((node) => [node.x ?? 0, node.y ?? 0]); +} + +function makeNodes(count: number): ForceNode[] { + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + // Distinct deterministic seeds (not all stacked on the origin). + nodes.push({ id: String(idx), x: idx * 7, y: (idx % 3) * 5, radius: 4 }); + } + return nodes; +} + +function layoutOf(nodes: ForceNode[], edges: ForceEdge[]): LayoutSimulation { + return createCommunityLayout(nodes, edges, new FlatGraphBuffer(nodes.length)); +} + +function distanceBetween( + positions: readonly [number, number][], + lhs: number, + rhs: number, +): number { + return Math.hypot( + positions[lhs]![0] - positions[rhs]![0], + positions[lhs]![1] - positions[rhs]![1], + ); +} + +describe("createCommunityLayout", () => { + it("settles, stays finite, and re-centres on the origin", () => { + const nodes = makeNodes(8); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + const positions = positionsOf(layout); + let sumX = 0; + let sumY = 0; + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + sumX += posX; + sumY += posY; + } + expect(Math.abs(sumX / positions.length)).toBeLessThan(1); + expect(Math.abs(sumY / positions.length)).toBeLessThan(1); + }); + + it("spreads nodes out (adjustSizes anti-overlap) rather than piling them", () => { + const nodes = makeNodes(12); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "4", target: "5", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + for (let lhs = 0; lhs < positions.length; lhs++) { + for (let rhs = lhs + 1; rhs < positions.length; rhs++) { + // Radius 4 each → centres must not be coincident (FA2 size-aware spacing). + expect(distanceBetween(positions, lhs, rhs)).toBeGreaterThan(4); + } + } + }); + + it("pulls a linked pair closer than the graph's overall spread", () => { + // Path 0-1-2-3-4: adjacent nodes attract; the path's ends sit far apart. + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + expect(distanceBetween(positions, 0, 1)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + }); + + it("detects Louvain communities (two cliques joined by a bridge → 2)", () => { + // Cliques {0,1,2} and {3,4,5}, joined only by the 2-3 bridge. + const nodes = makeNodes(6); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "0", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + { source: "4", target: "5", weight: 1 }, + { source: "3", target: "5", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const communities = layout.communities!; + expect(communities).toHaveLength(6); + expect(new Set(communities).size).toBe(2); + // The two cliques land in different communities. + expect(communities[0]).toBe(communities[1]); + expect(communities[0]).toBe(communities[2]); + expect(communities[3]).toBe(communities[4]); + expect(communities[3]).toBe(communities[5]); + expect(communities[0]).not.toBe(communities[3]); + }); + + it("is deterministic for identical inputs (seeded Louvain + seed + FA2)", () => { + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const first = layoutOf(makeNodes(4), [...edges]); + const second = layoutOf(makeNodes(4), [...edges]); + settle(first); + settle(second); + + const firstPositions = positionsOf(first); + const secondPositions = positionsOf(second); + for (let idx = 0; idx < firstPositions.length; idx++) { + expect(firstPositions[idx]![0]).toBeCloseTo(secondPositions[idx]![0], 2); + expect(firstPositions[idx]![1]).toBeCloseTo(secondPositions[idx]![1], 2); + } + }); + + it("warm-absorbs a new node in place (incremental, no restart)", () => { + // Two components: {0,1,2} and {3,4}. + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + expect(layout.nodes.length).toBe(5); + + // A new node "5" linked to existing "0": append + keep iterating (no restart). + const newNode: ForceNode = { id: "5", x: 0, y: 0, radius: 4 }; + layout.absorb!( + [newNode], + [...edges, { source: "0", target: "5", weight: 1 }], + ); + expect(layout.isSettled).toBe(false); // re-settling after the absorb + + settle(layout); + expect(layout.nodes.length).toBe(6); + expect(layout.communities!).toHaveLength(6); + + const positions = positionsOf(layout); + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + } + // The absorbed node is pulled toward its neighbour "0" (same component), so it + // lands closer to "0" than to "3" in the other component. + const indexOfId = (id: string): number => layout.nodeIds.indexOf(id); + expect( + distanceBetween(positions, indexOfId("5"), indexOfId("0")), + ).toBeLessThan(distanceBetween(positions, indexOfId("5"), indexOfId("3"))); + }); + + it("grows the SAB without losing state (ensureCapacity + absorb)", () => { + const nodes = makeNodes(4); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + // Capacity 4 — the absorb below overflows it, so the worker grows the buffer first + // (mirroring #absorbFlatNodes). The flat buffer is non-resizable (GPU-uploaded), so + // the grow RE-ALLOCATES + copies; the layout holds the SAME instance, so its warm FA2 + // state rides straight across (the instance's raw is swapped in place under it). + const buffer = new FlatGraphBuffer(4, () => {}); + const layout = createCommunityLayout(nodes, edges, buffer); + settle(layout); + + buffer.ensureCapacity(8); + layout.absorb!( + [{ id: "4", x: 0, y: 0, radius: 4 }], + [...edges, { source: "0", target: "4", weight: 1 }], + ); + settle(layout); + + expect(layout.nodes.length).toBe(5); + // The layout wrote positions into the grown buffer: record 4 (which would have + // silently overflowed the original capacity-4 buffer) matches node 4's position. + // (The worker's #writeFlatStyle owns `count`; layout owns x/y.) + const view = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const slots = FLAT_RECORD_BYTES / 4; + const node4 = layout.nodes[4]!; + expect(view[4 * slots]).toBeCloseTo(node4.x ?? 0, 3); // record 4, x + expect(view[4 * slots + 1]).toBeCloseTo(node4.y ?? 0, 3); // record 4, y + }); + + it("re-globalises (refreshes Louvain communities) after significant growth", () => { + const nodes = makeNodes(6); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + // A big batch (past the re-seed floor) each linked to an existing node: the + // absorb re-globalises, so Louvain re-runs and assigns every node a community + // (a warm-FA2-only absorb would leave the newcomers at -1). + const newNodes: ForceNode[] = []; + const grownEdges: ForceEdge[] = [...edges]; + for (let id = 6; id < 36; id++) { + newNodes.push({ id: String(id), x: 0, y: 0, radius: 4 }); + grownEdges.push({ + source: String(id), + target: String(id % 6), + weight: 1, + }); + } + layout.absorb!(newNodes, grownEdges); + settle(layout); + + expect(layout.nodes.length).toBe(36); + const communities = layout.communities!; + expect(communities).toHaveLength(36); + expect(communities.every((community) => community >= 0)).toBe(true); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts new file mode 100644 index 00000000000..633284f9532 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts @@ -0,0 +1,700 @@ +/** + * The community-force tier: the medium-scale individual-entity regime. Same + * displayed result as flat-force (individual nodes laid out by link structure, + * coloured by type, sized by degree) but a different engine. cola's `Descent` + * is O(N^2) per step and strands above a few hundred nodes, so here we use FA2 + * (Barnes-Hut, ~O(N log N) per step). The engine is a means; the view is + * identical (LAYOUT-MODES.md "engine split"). The pipeline (LAYOUT-MODES.md + * "flat-tier pipeline"): + * + * 1. Louvain over the link graph -> a community id per node. Membership only, + * no coordinates. Stored (exposed via `communities`) for the BubbleSets hulls + * that distinguish community-force from flat-force, and for future + * community-centroid seeding of streamed nodes. Seeded RNG -> deterministic. + * 2. Sparse-stress seed (`sparse-stress-seed.ts`): a pivot-stress + SGD pass over the link graph + * (~O(N^1.5), tick-budgeted). FA2 only finds a local minimum; from a random start a node that + * belongs on the left can strand on the right with no downhill path back. The seed lays the + * global structure down first so FA2 refines near the global minimum, not into a tangle, and + * packs disconnected components apart (FA2's repulsion then spaces nodes within each). + * 3. FA2 refine: `gravity` holds the components together (no packing step, so + * no "bounding box" like the cola tier), repulsion separates the piled + * components and spaces nodes within them, `adjustSizes` resolves overlap + * (replacing cola's VPSC pass). + * + * Both solvers are driven directly and stepped through the event-queue scheduler, + * streaming every step to the SharedArrayBuffer, never the blocking batch + * `forceAtlas2(graph, n)`. FA2's `iterate` runs over flat Float32Array matrices we + * build and own; it mutates the matrix and we read positions out, never writing + * them back mid-run (the same discipline as the cola driver: the solver owns every + * position). The shared buffer is the same interleaved `FlatGraphBuffer` flat-force + * fills, so the render path is shared, the tiers differ only in engine and (later) + * BubbleSets. + */ +import { UndirectedGraph } from "graphology"; +import louvain from "graphology-communities-louvain"; +import { + inferSettings, + type ForceAtlas2Settings, +} from "graphology-layout-forceatlas2"; +import iterate from "graphology-layout-forceatlas2/iterate"; + +import { parkMillerRng } from "../random"; +import { SparseStressSeeder } from "./sparse-stress-seed"; + +import type { Fa2Tuning } from "../../config"; +import type { FlatGraphBuffer } from "../buffers/position-buffer"; +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Floats per node / per edge in FA2's flat matrices (library-defined layout). */ +const PPN = 10; +const PPE = 3; +/** Node-record field offsets within the FA2 node matrix. */ +const NODE_X = 0; +const NODE_Y = 1; +const NODE_MASS = 6; +const NODE_SIZE = 8; + +/** Ideal layout-space length for one graph hop, fed to the sparse-stress seeder. */ +const SEED_IDEAL_LINK_LENGTH = 40; +/** Seeder work units consumed per #advance; the layout's ms budget bounds how many run per tick. */ +const SEED_TICK_WORK = 16384; +/** Non-overlap padding around each dot (world units), used as the FA2 node size. */ +const NODE_PAD = 3; +/** Deterministic init jitter the seeder applies (fraction of the ideal edge length): hash-based per + * node, so coincident nodes separate and FA2's 1/d repulsion has a direction -- a better FA2 seed + * than a post-hoc handoff offset. Tiny, so it does not disturb the seeded structure. */ +const SEED_JITTER = 0.01; + +/** + * FA2 settle: stop once the per-iteration node movement -- smoothed (EMA) and measured RELATIVE to + * the typical edge length -- has held below threshold for a sustained streak, NOT on a single + * iteration's dip (which is noisy and settles prematurely). BOTH the RMS move (the bulk residual) + * and the max move (the worst straggler) must be small for {@link FA2_SETTLE_STREAK} consecutive + * iterations, past {@link FA2_MIN_ITERS} and bounded by the {@link FA2_MAX_ITERS} safety cap. + */ +const FA2_MIN_ITERS = 120; +/** + * Runaway backstop only -- the settle detector is the real stop. Set well above any converging + * layout's needs so it never cuts a still-settling graph off early (the old 1500 did, on big dense + * graphs); it bites only if a pathological, non-converging layout would otherwise spread forever. + */ +const FA2_MAX_ITERS = 10000; +const FA2_SETTLE_RMS_REL = 0.0015; +const FA2_SETTLE_MAX_REL = 0.02; +const FA2_SETTLE_STREAK = 24; +/** EMA weight for the relative-move smoothing (higher = more reactive, lower = smoother). */ +const FA2_SETTLE_EMA_ALPHA = 0.15; +/** Re-estimate the typical-edge-length scale every N FA2 iterations (it drifts as FA2 settles). */ +const FA2_SCALE_REFRESH = 8; + +/** Per-iteration FA2 movement: the RMS (bulk residual) and the max (worst straggler), world units. */ +interface Fa2IterStats { + readonly rmsMove: number; + readonly maxMove: number; +} + +/** FA2's library defaults; merged under inferSettings + our overrides so every key + * `iterate` reads is present (a missing key would feed NaN into the matrix). */ +const FA2_DEFAULTS = { + linLogMode: false, + outboundAttractionDistribution: false, + adjustSizes: false, + edgeWeightInfluence: 1, + scalingRatio: 1, + strongGravityMode: false, + gravity: 1, + slowDown: 1, + barnesHutOptimize: false, + barnesHutTheta: 0.5, +} as const; + +/** + * FA2 settings: library defaults, then inferSettings' order-tuned values + * (scalingRatio, gravity, slowDown, and barnesHutOptimize only past ~2000 nodes), + * then our forced overrides: + * - `adjustSizes`: size-aware anti-overlap (active only on the exact-repulsion + * path inferSettings keeps us on below ~2000 nodes). + * - `linLogMode` on, `strongGravityMode` off: LinLog's logarithmic attraction + * pulls connected nodes tight and separates clusters (plain FA2 spreads edge- + * linked nodes into a hub-and-spoke ball); strong gravity is off so it does + * not crush that structure back toward the origin. + * An optional `tuning` (from `config.fa2`) overrides individual force fields on + * top of all of the above. + */ +function buildFa2Settings( + order: number, + tuning?: Fa2Tuning, +): ForceAtlas2Settings { + return { + ...FA2_DEFAULTS, + ...inferSettings(order), + adjustSizes: true, + linLogMode: true, + strongGravityMode: false, + ...(tuning?.gravity !== undefined ? { gravity: tuning.gravity } : {}), + ...(tuning?.scalingRatio !== undefined + ? { scalingRatio: tuning.scalingRatio } + : {}), + ...(tuning?.linLogMode !== undefined + ? { linLogMode: tuning.linLogMode } + : {}), + ...(tuning?.strongGravityMode !== undefined + ? { strongGravityMode: tuning.strongGravityMode } + : {}), + }; +} + +/** Refresh Louvain community membership once the layout has grown by ~this + * fraction since the last refresh, so the BubbleSets track the evolving + * communities. This is not a position re-seed: FA2 is the incremental global + * engine (new nodes arrive seeded beside their neighbours and it keeps + * tightening), and the sparse-stress seed runs once, at the initial build, never + * per re-globalise. Fires on proportional growth, not per batch. */ +const LOUVAIN_REFRESH_GROWTH_FRACTION = 0.3; +/** Floor, so a small graph still refreshes after a meaningful absolute growth. */ +const LOUVAIN_REFRESH_MIN_NEW_NODES = 24; + +/** A resolved index pair plus accumulated weight (parallel links merged). */ +interface IndexEdge { + readonly source: number; + readonly target: number; + readonly weight: number; +} + +type CommunityPhase = "seed" | "fa2" | "done"; + +class CommunityLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + /** Stable reference: the buffer grows in place (`ensureCapacity` on the instance the + * worker shares with us), so a warm absorb never needs to re-point it. */ + readonly #buffer: FlatGraphBuffer; + /** id -> matrix/buffer index. Extended (never reordered) on absorb, so existing + * records keep their slot and the shared buffer grows in place. */ + readonly #idToIndex = new Map(); + /** Louvain community id per node (buffer order); -1 until louvain runs, and for + * a freshly-absorbed node until the next debounced refresh. */ + readonly #communities: number[]; + #indexEdges: IndexEdge[] = []; + /** FA2 node matrix: `count * PPN` floats, fields at NODE_* offsets. FA2 owns it. */ + #nodeMatrix: Float32Array = new Float32Array(0); + /** FA2 edge matrix: `edges * PPE` floats, [sourceOffset, targetOffset, weight]. */ + #edgeMatrix: Float32Array = new Float32Array(0); + #fa2Settings: ForceAtlas2Settings; + readonly #fa2Tuning: Fa2Tuning | undefined; + /** Previous-iteration positions, for the FA2 max-move settle test. */ + #prevPositions: Float32Array = new Float32Array(0); + + #seed: SparseStressSeeder | null; + /** Seed positions the {@link SparseStressSeeder} writes into: read during the seed phase, then + * copied into the FA2 matrix on handoff. */ + #seedX: Float32Array = new Float32Array(0); + #seedY: Float32Array = new Float32Array(0); + #status: ForceLayoutStatus; + #phase: CommunityPhase; + #fa2Steps = 0; + /** Characteristic length the relative settle thresholds are measured against (typical edge + * length), refreshed every {@link FA2_SCALE_REFRESH} iterations as the layout tightens. */ + #fa2Scale = 1; + /** Consecutive iterations whose smoothed relative move is below threshold (the settle streak). */ + #fa2SettledFor = 0; + /** EMA of the relative RMS / max per-iteration move; +Inf until the first iteration seeds it. */ + #fa2RmsMoveEma = Number.POSITIVE_INFINITY; + #fa2MaxMoveEma = Number.POSITIVE_INFINITY; + /** New nodes absorbed since the last Louvain refresh (debounce counter). */ + #absorbedSinceLouvain = 0; + /** Node count at the last Louvain refresh, base for the growth-fraction trigger. */ + #countAtLastLouvain = 0; + + constructor( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + fa2Tuning?: Fa2Tuning, + ) { + this.#nodes = nodes; + this.#buffer = buffer; + const count = nodes.length; + + for (const [index, node] of nodes.entries()) { + this.#idToIndex.set(node.id, index); + } + this.#communities = Array.from({ length: count }).fill(-1); + this.#countAtLastLouvain = count; + this.#rebuildMatrices(edges); + // Louvain runs synchronously at build (O(E), cheap), so community ids are ready at commit time + // for the BubbleSets layer -- no separate channel / late re-emit. The streamed phases are then + // just seed -> fa2. + this.#runLouvain(); + + this.#fa2Tuning = fa2Tuning; + this.#fa2Settings = buildFa2Settings(count, fa2Tuning); + this.#seed = this.#buildSeed(count); + + this.#status = count > 0 ? "running" : "settled"; + this.#phase = count > 0 ? "seed" : "done"; + this.#writePositions(); + } + + /** + * (Re)build the FA2 matrices from the current `#nodes` and the given edges. + * Positions come from each node's mirrored x/y, so on absorb, existing nodes + * keep where they settled (warm) and newly-appended nodes start at their seed. + * Velocities reset to 0 (FA2 re-derives forces from positions each step, so the + * state that matters, positions, is preserved). mass = 1 + incident weight + * (hubs repel harder); size = dot radius + padding (for `adjustSizes`). + */ + #rebuildMatrices(edges: ForceEdge[]): void { + const count = this.#nodes.length; + this.#indexEdges = CommunityLayout.resolveEdges(edges, this.#idToIndex); + + const nodeMatrix = new Float32Array(count * PPN); + for (let idx = 0; idx < count; idx++) { + const node = this.#nodes[idx]!; + const base = idx * PPN; + nodeMatrix[base + NODE_X] = node.x ?? 0; + nodeMatrix[base + NODE_Y] = node.y ?? 0; + nodeMatrix[base + NODE_MASS] = 1; + nodeMatrix[base + NODE_SIZE] = node.radius + NODE_PAD; + } + + const edgeMatrix = new Float32Array(this.#indexEdges.length * PPE); + for (let edgeIdx = 0; edgeIdx < this.#indexEdges.length; edgeIdx++) { + const edge = this.#indexEdges[edgeIdx]!; + const base = edgeIdx * PPE; + edgeMatrix[base] = edge.source * PPN; + edgeMatrix[base + 1] = edge.target * PPN; + edgeMatrix[base + 2] = edge.weight; + const sourceMass = edge.source * PPN + NODE_MASS; + const targetMass = edge.target * PPN + NODE_MASS; + nodeMatrix[sourceMass] = nodeMatrix[sourceMass]! + edge.weight; + nodeMatrix[targetMass] = nodeMatrix[targetMass]! + edge.weight; + } + + this.#nodeMatrix = nodeMatrix; + this.#edgeMatrix = edgeMatrix; + this.#prevPositions = new Float32Array(count * 2); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#phase === "done" ? 0 : 1; + } + + /** Louvain community id per node, in buffer order (for BubbleSets / seeding). */ + get communities(): readonly number[] { + return this.#communities; + } + + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let stepped = false; + + while (performance.now() - startTime < budgetMs && this.#phase !== "done") { + this.#advance(); + stepped = true; + } + + if (stepped) { + this.#writePositions(); + } + if (this.#phase === "done") { + this.#status = "settled"; + } + return stepped; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + } + } + + /** + * Absorb newly-arrived nodes without a cold restart: append them at the end + * (existing indices keep their slot, so the shared buffer grows in place), rebuild + * the edge topology, and continue from the preserved positions. `edges` is the + * full current edge set (resolved by id, so order is irrelevant). + * + * New nodes arrive pre-seeded beside their neighbours (via the link store), so + * FA2 just re-settles locally, no reshuffle, no cold restart. Once the layout + * has grown by {@link LOUVAIN_REFRESH_GROWTH_FRACTION} it also refreshes Louvain, + * so the BubbleSets track the evolving communities. It never re-seeds: the sparse-stress seed + * runs once, at the initial build, and FA2 is the incremental global engine. This is what lets + * FA2 own the streaming tier; cola can't (fixed NxN Descent), so it has no `absorb`. + */ + absorb(newNodes: ForceNode[], edges: ForceEdge[]): void { + for (const node of newNodes) { + this.#idToIndex.set(node.id, this.#nodes.length); + this.#nodes.push(node); + this.#communities.push(-1); + } + this.#rebuildMatrices(edges); + const count = this.#nodes.length; + this.#fa2Settings = buildFa2Settings(count, this.#fa2Tuning); + this.#absorbedSinceLouvain += newNodes.length; + + const refreshAt = Math.max( + LOUVAIN_REFRESH_MIN_NEW_NODES, + Math.ceil(this.#countAtLastLouvain * LOUVAIN_REFRESH_GROWTH_FRACTION), + ); + if (count > 0 && this.#absorbedSinceLouvain >= refreshAt) { + // Grown enough that membership may have shifted: refresh Louvain so the + // BubbleSets track it. No re-seed: the sparse-stress seed runs once, at the + // initial build; FA2 keeps tightening from the current (neighbour-seeded) + // positions, which is the incremental global engine. + this.#runLouvain(); + this.#absorbedSinceLouvain = 0; + this.#countAtLastLouvain = count; + } + + // Always continue warm FA2, new nodes were seeded beside their neighbours, so + // it re-settles locally; never a cold re-seed. + this.#seed = null; + this.#phase = count > 0 ? "fa2" : "done"; + this.#fa2Steps = 0; + this.#resetFa2Settle(); + this.#status = count > 0 ? "running" : "settled"; + this.#writePositions(); + } + + /** + * Force a Louvain refresh if nodes were absorbed since the last one, the + * trailing-edge complement to the growth-fraction trigger. When a streaming + * burst goes quiet, the worker calls this so the BubbleSets reflect the final + * graph even if the last batch didn't cross the growth threshold. Position- + * neutral; returns whether it ran (so the caller knows to re-emit). + */ + refreshCommunities(): boolean { + if (this.#absorbedSinceLouvain === 0) { + return false; + } + this.#runLouvain(); + this.#absorbedSinceLouvain = 0; + this.#countAtLastLouvain = this.#nodes.length; + return true; + } + + /** One unit of work, advancing the phase machine. The active solver owns positions. */ + #advance(): void { + switch (this.#phase) { + case "seed": { + if (this.#seed!.tick({ maxWork: SEED_TICK_WORK }).done) { + this.#handOffSeedToFa2(); + this.#phase = "fa2"; + } + break; + } + case "fa2": { + iterate(this.#fa2Settings, this.#nodeMatrix, this.#edgeMatrix); + this.#fa2Steps += 1; + const converged = this.#afterFa2Iteration(this.#fa2IterStats()); + if (converged || this.#fa2Steps >= FA2_MAX_ITERS) { + this.#phase = "done"; + } + break; + } + default: + break; + } + } + + /** Run seeded Louvain over the link graph; fill `#communities` (singletons if no edges). */ + #runLouvain(): void { + if (this.#indexEdges.length === 0) { + for (let idx = 0; idx < this.#nodes.length; idx++) { + this.#communities[idx] = idx; + } + return; + } + + const graph = new UndirectedGraph< + Record, + { weight: number } + >(); + for (const node of this.#nodes) { + graph.addNode(node.id); + } + for (const edge of this.#indexEdges) { + graph.mergeEdge( + this.#nodes[edge.source]!.id, + this.#nodes[edge.target]!.id, + { + weight: edge.weight, + }, + ); + } + + const membership = louvain(graph, { + getEdgeWeight: "weight", + randomWalk: false, + rng: parkMillerRng(1), + }); + for (let idx = 0; idx < this.#nodes.length; idx++) { + this.#communities[idx] = membership[this.#nodes[idx]!.id] ?? idx; + } + } + + /** + * Build the sparse-stress seed: a {@link SparseStressSeeder} over the link graph (pivot-stress + + * SGD, ~O(N^1.5), tick-budgeted) that lays the global structure down for FA2 to refine, writing + * into {@link #seedX}/{@link #seedY}. Returns null for the empty graph. + */ + #buildSeed(count: number): SparseStressSeeder | null { + if (count === 0) { + return null; + } + const edgeCount = this.#indexEdges.length; + const src = new Uint32Array(edgeCount); + const dst = new Uint32Array(edgeCount); + for (let idx = 0; idx < edgeCount; idx++) { + const edge = this.#indexEdges[idx]!; + src[idx] = edge.source; + dst[idx] = edge.target; + } + + this.#seedX = new Float32Array(count); + this.#seedY = new Float32Array(count); + + return new SparseStressSeeder( + { n: count, src, dst, x: this.#seedX, y: this.#seedY }, + { + idealEdgeLength: SEED_IDEAL_LINK_LENGTH, + // Deterministic, hash-based init jitter (driven by randomSeed) so coincident nodes separate + // for FA2's 1/d repulsion -- no post-handoff offset needed. + randomSeed: 1, + jitter: SEED_JITTER, + packComponents: true, + returnPivotDistances: false, + }, + ); + } + + /** Copy the settled seed positions into the FA2 matrix; drop the seed. The seeder already applied + * its deterministic jitter, so the positions are coincidence-free for FA2's 1/d repulsion. */ + #handOffSeedToFa2(): void { + for (let idx = 0; idx < this.#nodes.length; idx++) { + const base = idx * PPN; + this.#nodeMatrix[base + NODE_X] = this.#seedX[idx]!; + this.#nodeMatrix[base + NODE_Y] = this.#seedY[idx]!; + } + this.#seed = null; + } + + /** + * Per-iteration node movement (world units) versus the previous iteration: the RMS (the bulk + * residual) and the max (the worst straggler). Updates the previous-position snapshot. + */ + #fa2IterStats(): Fa2IterStats { + let maxSq = 0; + let sumSq = 0; + const count = this.#nodes.length; + for (let idx = 0; idx < count; idx++) { + const base = idx * PPN; + const x = this.#nodeMatrix[base + NODE_X]!; + const y = this.#nodeMatrix[base + NODE_Y]!; + const dx = x - this.#prevPositions[idx * 2]!; + const dy = y - this.#prevPositions[idx * 2 + 1]!; + const sq = dx * dx + dy * dy; + if (sq > maxSq) { + maxSq = sq; + } + sumSq += sq; + this.#prevPositions[idx * 2] = x; + this.#prevPositions[idx * 2 + 1] = y; + } + return { + maxMove: Math.sqrt(maxSq), + rmsMove: count > 0 ? Math.sqrt(sumSq / count) : 0, + }; + } + + /** + * Fold one iteration's movement into the settle state and report whether the layout has + * converged. The relative RMS + max move are EMA-smoothed (versus the periodically-refreshed + * typical edge length) and must BOTH hold below threshold for {@link FA2_SETTLE_STREAK} + * consecutive iterations past {@link FA2_MIN_ITERS} -- so a single noisy dip never settles early. + */ + #afterFa2Iteration(stats: Fa2IterStats): boolean { + if (this.#fa2Steps === 1 || this.#fa2Steps % FA2_SCALE_REFRESH === 0) { + this.#fa2Scale = this.#estimateTypicalEdgeLength(); + } + const scale = Math.max(1e-6, this.#fa2Scale); + const rmsRel = stats.rmsMove / scale; + const maxRel = stats.maxMove / scale; + + if (!Number.isFinite(this.#fa2RmsMoveEma)) { + this.#fa2RmsMoveEma = rmsRel; + this.#fa2MaxMoveEma = maxRel; + } else { + this.#fa2RmsMoveEma = + this.#fa2RmsMoveEma * (1 - FA2_SETTLE_EMA_ALPHA) + + rmsRel * FA2_SETTLE_EMA_ALPHA; + this.#fa2MaxMoveEma = + this.#fa2MaxMoveEma * (1 - FA2_SETTLE_EMA_ALPHA) + + maxRel * FA2_SETTLE_EMA_ALPHA; + } + + const settledNow = + this.#fa2RmsMoveEma < FA2_SETTLE_RMS_REL && + this.#fa2MaxMoveEma < FA2_SETTLE_MAX_REL; + this.#fa2SettledFor = settledNow ? this.#fa2SettledFor + 1 : 0; + + return ( + this.#fa2Steps >= FA2_MIN_ITERS && + this.#fa2SettledFor >= FA2_SETTLE_STREAK + ); + } + + /** Reset the FA2 settle smoothing + streak (a warm absorb re-energises the layout). */ + #resetFa2Settle(): void { + this.#fa2SettledFor = 0; + this.#fa2RmsMoveEma = Number.POSITIVE_INFINITY; + this.#fa2MaxMoveEma = Number.POSITIVE_INFINITY; + } + + /** + * The characteristic length the relative settle thresholds normalise against: the mean current + * edge length (linked nodes sit ~one edge apart), or -- with no edges -- the RMS spread about the + * origin (FA2's gravity keeps the layout centred there). Scale-invariant, so the thresholds hold + * regardless of the layout's absolute size. + */ + #estimateTypicalEdgeLength(): number { + const edges = this.#indexEdges; + if (edges.length > 0) { + let sum = 0; + for (const edge of edges) { + const a = edge.source * PPN; + const b = edge.target * PPN; + const dx = + this.#nodeMatrix[a + NODE_X]! - this.#nodeMatrix[b + NODE_X]!; + const dy = + this.#nodeMatrix[a + NODE_Y]! - this.#nodeMatrix[b + NODE_Y]!; + sum += Math.sqrt(dx * dx + dy * dy); + } + return sum / edges.length; + } + + const count = this.#nodes.length; + if (count === 0) { + return 1; + } + let sumSq = 0; + for (let idx = 0; idx < count; idx++) { + const base = idx * PPN; + const x = this.#nodeMatrix[base + NODE_X]!; + const y = this.#nodeMatrix[base + NODE_Y]!; + sumSq += x * x + y * y; + } + return Math.sqrt(sumSq / count); + } + + /** + * Read positions from whichever solver is active, re-centre on the origin, write + * the shared buffer + mirror the ForceNode view (so a warm-start can read settled + * coords). + */ + #writePositions(): void { + const count = this.#nodes.length; + const inSeed = this.#phase === "seed"; + + let centroidX = 0; + let centroidY = 0; + for (let idx = 0; idx < count; idx++) { + centroidX += inSeed + ? this.#seedX[idx]! + : this.#nodeMatrix[idx * PPN + NODE_X]!; + centroidY += inSeed + ? this.#seedY[idx]! + : this.#nodeMatrix[idx * PPN + NODE_Y]!; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + for (let idx = 0; idx < count; idx++) { + const rawX = inSeed + ? this.#seedX[idx]! + : this.#nodeMatrix[idx * PPN + NODE_X]!; + const rawY = inSeed + ? this.#seedY[idx]! + : this.#nodeMatrix[idx * PPN + NODE_Y]!; + const localX = rawX - centroidX; + const localY = rawY - centroidY; + this.#nodes[idx]!.x = localX; + this.#nodes[idx]!.y = localY; + this.#buffer.setPosition(idx, localX, localY); + } + this.#buffer.commit(); + } + + /** Resolve string/object endpoints to index pairs, drop self/dangling, merge parallels. */ + private static resolveEdges( + edges: ForceEdge[], + idToIndex: Map, + ): IndexEdge[] { + const weightByPair = new Map(); + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + const lo = Math.min(source, target); + const hi = Math.max(source, target); + const key = `${lo}:${hi}`; + const existing = weightByPair.get(key); + weightByPair.set(key, { + source: lo, + target: hi, + weight: (existing?.weight ?? 0) + edge.weight, + }); + } + return [...weightByPair.values()]; + } +} + +export function createCommunityLayout( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + fa2Tuning?: Fa2Tuning, +): LayoutSimulation { + return new CommunityLayout(nodes, edges, buffer, fa2Tuning); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/entity-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/entity-layout.ts new file mode 100644 index 00000000000..b5cef977ee9 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/entity-layout.ts @@ -0,0 +1,111 @@ +/** + * Force layout for individual entities inside an opened leaf cluster. + * + * Entities should fill their bubble compactly: weak charge, collision spacing, + * a gentle spring toward the center, and hard confinement to the bubble. Link + * springs pull connected entities together. This is deliberately not the + * cluster layout: bubbles need to spread and route edges; dots need to pack. + * + * On top of that, a port-attraction force pulls each entity toward the rim point + * where its external connection leaves the leaf (its port target). This replaces + * the old "fan out to a baked exit" model: the dots cluster near their real + * exits, so the fan-out lines stay short and legible. The target array is shared + * with (and updated live by) the worker, so as ports re-slot the dots follow, + * with no baked, going-stale positions. + * + * The port pull does not fully win: the gentle center spring stays on for every + * dot, so a targeted dot settles a bit inside the rim (a blend of port + centre) + * rather than jammed against it, which keeps dots legible and lets a dot with + * several external ports sit sensibly between them instead of being torn to one. + */ +import { + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, +} from "d3-force"; + +import { ForceSimulation } from "./force-simulation"; + +import type { ForceEdge, ForceNode } from "./force-simulation"; +import type { Force } from "d3-force"; + +/** Gentle pull toward the bubble center; collision does the real spacing. */ +const CENTER_STRENGTH = 0.05; +/** Pull toward the external port target; blends with (does not erase) center. */ +const PORT_ATTRACTION_STRENGTH = 0.2; + +/** + * Pull each entity toward its port target `(targets[2i], targets[2i+1])`, in the + * leaf's local frame. A NaN target means "no external connection", so that + * entity is left to the center/charge forces. `targets` is owned by the worker and + * mutated in place as ports re-slot; this force reads it live each tick. + */ +function forcePortAttraction( + targets: Float32Array, + strength: number, +): Force { + let nodes: ForceNode[] = []; + + const force: Force = (alpha) => { + for (let idx = 0; idx < nodes.length; idx++) { + const tx = targets[idx * 2]!; + if (Number.isNaN(tx)) { + continue; + } + const ty = targets[idx * 2 + 1]!; + const node = nodes[idx]!; + node.vx = (node.vx ?? 0) + (tx - (node.x ?? 0)) * strength * alpha; + node.vy = (node.vy ?? 0) + (ty - (node.y ?? 0)) * strength * alpha; + } + }; + + force.initialize = (newNodes: ForceNode[]) => { + nodes = newNodes; + }; + + return force; +} + +export function createEntityLayout( + nodes: ForceNode[], + edges: ForceEdge[], + confinementRadius: number, + portTargets?: Float32Array, +): ForceSimulation { + const simulation = forceSimulation(nodes) + .force("charge", forceManyBody().strength(-1).distanceMax(50)) + .force( + "collide", + forceCollide((node) => node.radius + 1).iterations(4), + ) + .force( + "link", + forceLink(edges) + .id((node) => node.id) + .distance( + (edge) => + ((edge.source as ForceNode).radius + + (edge.target as ForceNode).radius) * + 2 + + 10, + ) + .strength((edge) => Math.min(1, 0.3 * edge.weight)), + ) + .force("centerX", forceX(0).strength(CENTER_STRENGTH)) + .force("centerY", forceY(0).strength(CENTER_STRENGTH)) + .alphaDecay(0.015) + .velocityDecay(0.35) + .stop(); + + if (portTargets) { + simulation.force( + "port", + forcePortAttraction(portTargets, PORT_ATTRACTION_STRENGTH), + ); + } + + return new ForceSimulation(nodes, simulation, confinementRadius); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.test.ts new file mode 100644 index 00000000000..e3dbb7e8fc6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.test.ts @@ -0,0 +1,173 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createFlatLayout } from "./flat-layout"; + +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +function settle(layout: LayoutSimulation): void { + for (let step = 0; step < 1000 && !layout.isSettled; step++) { + layout.tick(50); + } +} + +function positionsOf(layout: LayoutSimulation): [number, number][] { + return layout.nodes.map((node) => [node.x ?? 0, node.y ?? 0]); +} + +function makeNodes(count: number): ForceNode[] { + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + // Distinct deterministic seeds (not all stacked on the origin). + nodes.push({ id: String(idx), x: idx * 7, y: (idx % 3) * 5, radius: 4 }); + } + return nodes; +} + +function layoutOf(nodes: ForceNode[], edges: ForceEdge[]): LayoutSimulation { + return createFlatLayout(nodes, edges, new FlatGraphBuffer(nodes.length)); +} + +function distanceBetween( + positions: readonly [number, number][], + lhs: number, + rhs: number, +): number { + return Math.hypot( + positions[lhs]![0] - positions[rhs]![0], + positions[lhs]![1] - positions[rhs]![1], + ); +} + +describe("createFlatLayout", () => { + it("settles, stays finite, and re-centres on the origin", () => { + const nodes = makeNodes(8); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + const positions = positionsOf(layout); + let sumX = 0; + let sumY = 0; + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + sumX += posX; + sumY += posY; + } + expect(Math.abs(sumX / positions.length)).toBeLessThan(1); + expect(Math.abs(sumY / positions.length)).toBeLessThan(1); + }); + + it("does not pile nodes on top of each other", () => { + const nodes = makeNodes(10); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + for (let lhs = 0; lhs < positions.length; lhs++) { + for (let rhs = lhs + 1; rhs < positions.length; rhs++) { + // Radius 4 each → centres must be clearly apart (non-overlap box). + expect(distanceBetween(positions, lhs, rhs)).toBeGreaterThan(4); + } + } + }); + + it("pulls a linked pair closer than the graph's overall spread", () => { + // Path 0-1-2-3-4: adjacent nodes sit near the ideal length; ends far apart. + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + expect(distanceBetween(positions, 0, 1)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + expect(distanceBetween(positions, 3, 4)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + }); + + it("is deterministic for identical seed positions", () => { + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const first = layoutOf(makeNodes(4), [...edges]); + const second = layoutOf(makeNodes(4), [...edges]); + settle(first); + settle(second); + + const firstPositions = positionsOf(first); + const secondPositions = positionsOf(second); + for (let idx = 0; idx < firstPositions.length; idx++) { + expect(firstPositions[idx]![0]).toBeCloseTo(secondPositions[idx]![0], 4); + expect(firstPositions[idx]![1]).toBeCloseTo(secondPositions[idx]![1], 4); + } + }); + + // Perf probe (skipped — ~6.6s). cola's Descent is the small-N (flat-force) + // engine; this confirms it stays robust at the TOP of its range — 1000 nodes in + // one connected component is the worst case for its O(N^2) (real data is many + // small components, far faster). The community-force tier uses the Louvain -> + // SMACOF-seed -> FA2 pipeline (#26) — a different engine for a different scale, + // not a fallback for when this is slow. Un-skip to re-benchmark. + it.skip("settles ~1000 nodes (worst case: one connected component) and times it", () => { + const count = 1000; + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + nodes.push({ + id: String(idx), + x: (idx % 40) * 20, + y: Math.floor(idx / 40) * 20, + radius: 5, + }); + } + // One connected component (chain + cross-links): a fully-connected graph is + // the WORST case for cola's O(N^2) (dense distance matrix, slow convergence) — + // heavier than the real many-small-components data. + const edges: ForceEdge[] = []; + for (let idx = 1; idx < count; idx++) { + edges.push({ source: String(idx - 1), target: String(idx), weight: 1 }); + if (idx >= 7) { + edges.push({ source: String(idx - 7), target: String(idx), weight: 1 }); + } + } + + const start = performance.now(); + const layout = createFlatLayout(nodes, edges, new FlatGraphBuffer(count)); + let batches = 0; + while (!layout.isSettled && batches < 2000) { + layout.tick(1000); + batches += 1; + } + const elapsed = performance.now() - start; + // eslint-disable-next-line no-console + console.log( + `[bench] ${count} nodes / ${edges.length} edges (connected) -> settle ` + + `${elapsed.toFixed(0)}ms over ${batches} batches`, + ); + expect(layout.isSettled).toBe(true); + }, 120000); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.ts new file mode 100644 index 00000000000..4cba4892bdc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/flat-layout.ts @@ -0,0 +1,397 @@ +/** + * The flat-tier layout: one stress-minimised embedding of the whole entity set, + * used by `flat-force` and `community-force` (the individual-entity regime, see + * `LAYOUT-MODES.md`). This is not the cluster (macro) layout: there are no + * containers, no ports, every node is an individual entity. + * + * We drive WebCola's `Descent` solver directly rather than via its `Layout` + * class. `Layout.start()` runs its phases (unconstrained stress -> overlap) to + * completion synchronously; that fights our model, where every step must go + * through the event-queue scheduler and stream to the SharedArrayBuffer. So we + * own the sequencing and stage the phases across `tick()` calls: + * + * - Phase A (unconstrained stress majorisation): `descent.project = null`; step + * `rungeKutta()` until stress converges. cola unfolds the graph by pure + * stress, link communities separating spatially (jaccard-weighted ideal link + * lengths). Disconnected pairs have an infinite ideal distance, which cola's + * gradient treats as zero force (harmless), so components drift freely here. + * - Pack: `separateGraphs` + `applyPacking` arrange the (now-settled) connected + * components compactly, the geometric step the stress phase can't do. + * - Phase B (VPSC non-overlap): `descent.project = Projection(...).projectFunctions()` + * with `avoidOverlaps`; step `rungeKutta()` for a fixed iteration budget. Not + * "until stress converges", VPSC is nearly stress-neutral, so a convergence + * test quits before overlap is resolved (this is how `Layout` does it too). + * + * Every step writes the shared buffer and is published, so the whole settling + * streams. cola owns every position throughout, we never mutate its node + * coordinates behind its back (doing so corrupts its descent + VPSC state and + * piles nodes up). + */ +import { + Calculator, + Descent, + Projection, + applyPacking, + jaccardLinkLengths, + separateGraphs, +} from "webcola"; + +import type { FlatGraphBuffer } from "../buffers/position-buffer"; +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Base ideal link length (world units); jaccardLinkLengths scales it by structure. */ +const IDEAL_LINK_LENGTH = 40; +/** jaccardLinkLengths weighting: how strongly neighbourhood overlap warps lengths. */ +const JACCARD_WEIGHT = 1; +/** Non-overlap padding around each dot, in world units. */ +const NODE_PAD = 3; +/** Stress-convergence threshold for Phase A (cola's own default ratio test). */ +const CONVERGENCE_THRESHOLD = 0.01; +/** Phase B (overlap): run a guaranteed base of iterations to get past VPSC's + * early stress-neutral steps (where a convergence test trips immediately), then + * switch to displacement-convergence (keep descending until the layout floors, + * stable), bounded by a high safety cap. We're in the worker streaming through + * the queue, so the extra iterations cost the UI nothing. */ +const OVERLAP_MIN_ITERS = 40; +const OVERLAP_MAX_ITERS = 400; +/** Phase A safety cap: bound the unconstrained stress phase like cola's run(). + * rungeKutta() returns displacement, which ->0 at settle, so the ratio test never + * trips and the cap is what guarantees termination. */ +const STRESS_MAX_ITERS = 200; +/** Fallback node size (world units) for the disconnected-component packing. */ +const PACK_NODE_SIZE = 16; + +/** + * A node as cola's `Projection` / packing read it: positions (rebuilt into + * `bounds` by the projection each step) + the box used for non-overlap. + */ +interface ColaNode { + index: number; + x: number; + y: number; + width: number; + height: number; +} + +/** Index-based link (its jaccard length is kept in a side map, not on the link). */ +interface ColaLink { + readonly source: number; + readonly target: number; +} + +/** Object-endpoint link for `separateGraphs` (it reads `source.index`). */ +interface ColaObjectLink { + source: ColaNode; + target: ColaNode; +} + +type FlatPhase = "stress" | "pack" | "overlap" | "done"; + +class FlatLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + readonly #colaNodes: ColaNode[]; + readonly #objectLinks: ColaObjectLink[]; + readonly #rootGroup: { leaves: ColaNode[]; groups: never[]; padding: number }; + readonly #descent: Descent; + /** Weight matrix (default 2 = push-apart-only; 1 for edges). Attached for the + * overlap phase only, exactly as `Layout.start` does. */ + readonly #weights: number[][]; + readonly #buffer: FlatGraphBuffer; + #status: ForceLayoutStatus; + #phase: FlatPhase; + #prevDisp = Number.MAX_VALUE; + #stressSteps = 0; + #overlapSteps = 0; + + constructor(nodes: ForceNode[], edges: ForceEdge[], buffer: FlatGraphBuffer) { + this.#nodes = nodes; + this.#buffer = buffer; + const count = nodes.length; + + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + + this.#colaNodes = nodes.map((node, index) => ({ + index, + x: node.x ?? 0, + y: node.y ?? 0, + width: (node.radius + NODE_PAD) * 2, + height: (node.radius + NODE_PAD) * 2, + })); + + const links: ColaLink[] = []; + this.#objectLinks = []; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const sourceIndex = idToIndex.get(sourceId); + const targetIndex = idToIndex.get(targetId); + if ( + sourceIndex !== undefined && + targetIndex !== undefined && + sourceIndex !== targetIndex + ) { + links.push({ source: sourceIndex, target: targetIndex }); + this.#objectLinks.push({ + source: this.#colaNodes[sourceIndex]!, + target: this.#colaNodes[targetIndex]!, + }); + } + } + + // Neighbourhood-aware ideal lengths: jaccardLinkLengths writes link.length + // (= 1 + w * jaccard); the distance fed to Dijkstra is then idealLength * length. + const lengthByLink = new Map(); + const accessor = { + getSourceIndex: (link: ColaLink) => link.source, + getTargetIndex: (link: ColaLink) => link.target, + setLength: (link: ColaLink, value: number) => { + lengthByLink.set(link, value); + }, + }; + jaccardLinkLengths(links, accessor, JACCARD_WEIGHT); + + const distances = new Calculator( + count, + links, + accessor.getSourceIndex, + accessor.getTargetIndex, + (link) => IDEAL_LINK_LENGTH * (lengthByLink.get(link) ?? 1), + ).DistanceMatrix(); + const distanceMatrix = Descent.createSquareMatrix( + count, + (row, col) => distances[row]![col]!, + ); + + // Weights: 2 everywhere (non-adjacent, only repel when too close), 1 for + // edges (pulled to ideal). Matches Layout.start; attached for the overlap pass. + this.#weights = Descent.createSquareMatrix(count, () => 2); + for (const link of links) { + this.#weights[link.source]![link.target] = 1; + this.#weights[link.target]![link.source] = 1; + } + + const xs = this.#colaNodes.map((node) => node.x); + const ys = this.#colaNodes.map((node) => node.y); + this.#descent = new Descent([xs, ys], distanceMatrix); + this.#descent.threshold = CONVERGENCE_THRESHOLD; + // `descent.project` is null after construction, so Phase A is unconstrained + // (computeNextPosition guards `if (this.project)`). Phase B sets the overlap + // Projection. (cola's .d.ts mistypes `project` as non-nullable, hence no assign.) + + this.#rootGroup = { leaves: this.#colaNodes, groups: [], padding: 1 }; + + this.#status = count > 0 ? "running" : "settled"; + this.#phase = count > 0 ? "stress" : "done"; + this.#writePositions(); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#phase === "done" ? 0 : CONVERGENCE_THRESHOLD; + } + + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let stepped = false; + + while (performance.now() - startTime < budgetMs && this.#phase !== "done") { + this.#advance(); + stepped = true; + } + + if (stepped) { + this.#writePositions(); + } + if (this.#phase === "done") { + this.#status = "settled"; + } + return stepped; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + } + } + + /** One unit of work, advancing the phase machine. cola owns every position. */ + #advance(): void { + switch (this.#phase) { + case "stress": { + const disp = this.#descent.rungeKutta(); + this.#stressSteps += 1; + const converged = + Number.isFinite(disp) && + disp > 0 && + Math.abs(this.#prevDisp / disp - 1) < CONVERGENCE_THRESHOLD; + this.#prevDisp = disp; + if ( + converged || + disp === 0 || + !Number.isFinite(disp) || + this.#stressSteps >= STRESS_MAX_ITERS + ) { + this.#phase = "pack"; + } + break; + } + case "pack": { + this.#packComponents(); + // Switch on VPSC non-overlap. Projection rebuilds each node's bounds from + // the descent's position arrays every step, so we needn't sync node x/y. + const projection = new Projection( + // cola's loose GraphNode/Group types, boundary casts. constraints must + // be [] not null: Projection only inits xConstraints/yConstraints when + // `constraints` is truthy, and project() does `xConstraints.concat(...)` + // (Layout passes [] here too). + this.#colaNodes as never, + [], + this.#rootGroup as never, + [], + true, + ); + this.#descent.project = projection.projectFunctions(); + this.#descent.G = this.#weights; + this.#overlapSteps = 0; + this.#prevDisp = Number.MAX_VALUE; + this.#phase = "overlap"; + break; + } + case "overlap": { + const disp = this.#descent.rungeKutta(); + this.#overlapSteps += 1; + if (this.#overlapSteps <= OVERLAP_MIN_ITERS) { + // Guaranteed base: VPSC is stress-neutral at first, so a convergence + // test would quit before overlap resolves. Run the base, then trust it. + this.#prevDisp = disp; + break; + } + // Past the base: stop once the descent floors (stable), same ratio test + // as Phase A, or at the safety cap. + const converged = + disp === 0 || + (Number.isFinite(disp) && + disp > 0 && + Math.abs(this.#prevDisp / disp - 1) < CONVERGENCE_THRESHOLD); + this.#prevDisp = disp; + if ( + converged || + !Number.isFinite(disp) || + this.#overlapSteps >= OVERLAP_MAX_ITERS + ) { + this.#phase = "done"; + } + break; + } + default: + break; + } + } + + /** + * Pack the connected components compactly (the geometric step stress can't do, + * disconnected components have no inter-force). `applyPacking` works on node + * x/y, so sync from the descent, pack, sync back. Mirrors + * `Layout.separateOverlappingComponents`. + */ + #packComponents(): void { + const xs = this.#descent.x[0]!; + const ys = this.#descent.x[1]!; + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (let idx = 0; idx < this.#colaNodes.length; idx++) { + const node = this.#colaNodes[idx]!; + node.x = xs[idx]!; + node.y = ys[idx]!; + minX = Math.min(minX, node.x); + maxX = Math.max(maxX, node.x); + minY = Math.min(minY, node.y); + maxY = Math.max(maxY, node.y); + } + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + + const graphs = separateGraphs(this.#colaNodes, this.#objectLinks as never); + applyPacking(graphs, width, height, PACK_NODE_SIZE, 1, true); + + for (let idx = 0; idx < this.#colaNodes.length; idx++) { + const node = this.#colaNodes[idx]!; + xs[idx] = node.x; + ys[idx] = node.y; + } + } + + /** Read cola's positions, re-centre on the origin, into the shared buffer + ForceNode view. */ + #writePositions(): void { + const count = this.#nodes.length; + const xs = this.#descent.x[0]!; + const ys = this.#descent.x[1]!; + let centroidX = 0; + let centroidY = 0; + for (let idx = 0; idx < count; idx++) { + centroidX += xs[idx]!; + centroidY += ys[idx]!; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + for (let idx = 0; idx < count; idx++) { + const localX = xs[idx]! - centroidX; + const localY = ys[idx]! - centroidY; + // Mirror into the ForceNode view so a warm-start can read settled coords. + this.#nodes[idx]!.x = localX; + this.#nodes[idx]!.y = localY; + this.#buffer.setPosition(idx, localX, localY); + } + this.#buffer.commit(); + } +} + +export function createFlatLayout( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, +): LayoutSimulation { + return new FlatLayout(nodes, edges, buffer); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.ts new file mode 100644 index 00000000000..d4a0f9df83f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.ts @@ -0,0 +1,285 @@ +import { EntityPositionBuffer } from "../buffers/position-buffer"; + +/** + * Shared mechanics for a SharedArrayBuffer-backed d3-force simulation: + * position storage, time-budgeted ticking, settle detection, and circular + * confinement. The forces differ per use and are configured by the dedicated + * factory ({@link "./entity-layout"}); this base only owns what they have in + * common. (Cluster/macro layout is handled by WebCola in {@link + * "./cluster-layout"}; this engine now backs only the entity-dot layouts.) + * + * Positions are local to the parent (centered at 0,0); the caller translates to + * world coords using the parent's position. A version counter at the start of + * the buffer lets the main thread detect changes via Atomics. + */ +import type { Force, Simulation, SimulationNodeDatum } from "d3-force"; + +export { sharedBufferAvailable } from "../buffers/growable-buffer"; + +export interface ForceNode extends SimulationNodeDatum { + readonly id: string; + readonly radius: number; +} + +export interface ForceEdge { + source: string | ForceNode; + target: string | ForceNode; + readonly weight: number; +} + +export type ForceLayoutStatus = "running" | "paused" | "settled"; + +/** + * A fixed external-port anchor for a sub-cluster layout. The anchor is pinned to + * the parent rim in the direction of an external neighbour; the children that + * connect through it are linked to it, so the layout sorts them toward their + * real external connections (WebCola constraint, only the cluster layout + * supports it). + */ +export interface PortAnchor { + /** Anchor position in the layout's local frame (on the parent rim). */ + readonly x: number; + readonly y: number; + /** + * Children linked to this port: index into the layout's nodes + the pull + * weight (proportional to the edge count to this port, so heavily-connected + * children are held to their port more firmly than weakly-connected ones). + */ + readonly children: readonly { + readonly index: number; + readonly weight: number; + }[]; +} + +/** + * The position-producing surface every layout engine exposes to the worker, + * implemented by the d3-force {@link ForceSimulation} (entity dots) and the + * WebCola cluster layout. The scheduler, SharedArrayBuffer plumbing, and + * LAYOUT_CREATED message depend only on this, so the two engines are + * interchangeable. + */ +export interface LayoutSimulation { + readonly status: ForceLayoutStatus; + readonly isSettled: boolean; + readonly nodes: readonly ForceNode[]; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + readonly nodeIds: string[]; + readonly alpha: number; + /** Louvain community id per node in buffer order (community-force layout only). */ + readonly communities?: readonly number[]; + tick(budgetMs: number): boolean; + pause(): void; + resume(): void; + /** + * Incrementally absorb newly-arrived nodes without a restart (community-force / + * FA2 only): append them (pre-seeded near their neighbours), rebuild edge + * topology from `edges` (the full current set), and keep iterating from current + * positions. cola can't (its Descent is a fixed N×N solve), so the flat-force + * tier omits this and rebuilds (warm-seeded) instead. + */ + absorb?(newNodes: ForceNode[], edges: ForceEdge[]): void; + /** Write a node's rgba colour into the buffer (entity-dot leaves carry per-node colour). */ + setNodeColor?( + index: number, + color: readonly [number, number, number, number], + ): void; + /** Publish colour writes -- bumps the version so the main thread re-uploads. */ + commitColors?(): void; + /** + * Force a community (Louvain) refresh if any nodes were absorbed since the last + * one; returns whether it ran. Position-neutral. The worker calls this on a + * trailing debounce (stream goes quiet) so membership reflects the final graph. + */ + refreshCommunities?(): boolean; + /** Re-run with fixed external-port anchors (cluster layout only). */ + setPortAnchors?(anchors: readonly PortAnchor[]): void; + /** Move existing port anchors in place (no re-run); children track them. */ + updateAnchorPositions?( + positions: readonly { readonly x: number; readonly y: number }[], + ): void; +} + +/** + * Freeze a layout once its energy drops to here: d3's natural settle floor. + * A higher threshold (the old 0.01) freezes early, before the low-energy + * collision pass has nudged the last overlaps apart; running down to 0.001 + * lets the layout fully relax (the "nicer layout when left alone" effect). + */ +const SETTLE_ALPHA = 0.001; + +/** + * Custom d3 force that confines nodes inside a circle. Runs as part of the + * simulation tick (adjusts velocities) rather than as a post-processing step, + * so it interacts correctly with the other forces. + */ +export function forceConfine(radius: number): Force { + let nodes: ForceNode[] = []; + + const force: Force = () => { + for (const node of nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + const dist = Math.hypot(x, y); + const boundary = Math.max(0, radius * 0.98 - node.radius); + + if (boundary <= 0) { + node.vx = -(node.x ?? 0) * 0.5; + node.vy = -(node.y ?? 0) * 0.5; + } else if (dist > boundary) { + const overshoot = dist - boundary; + const strength = 0.3 + 0.7 * Math.min(1, overshoot / radius); + node.vx = (node.vx ?? 0) - (x / dist) * overshoot * strength; + node.vy = (node.vy ?? 0) - (y / dist) * overshoot * strength; + } + } + }; + + force.initialize = (newNodes: ForceNode[]) => { + nodes = newNodes; + }; + + return force; +} + +/** + * Owns a pre-configured (and `.stop()`'d) d3 simulation plus its + * SharedArrayBuffer-backed position storage. The factory builds the forces and + * hands the simulation + * here; this class drives it. + */ +export class ForceSimulation implements LayoutSimulation { + readonly #simulation: Simulation; + readonly #nodes: ForceNode[]; + readonly #confinementRadius: number | undefined; + readonly #positionBuffer: EntityPositionBuffer; + #status: ForceLayoutStatus; + + constructor( + nodes: ForceNode[], + simulation: Simulation, + confinementRadius?: number, + ) { + this.#nodes = nodes; + this.#confinementRadius = confinementRadius; + this.#status = "running"; + this.#positionBuffer = new EntityPositionBuffer(nodes.length); + this.#writePositions(); + + this.#simulation = simulation; + if (confinementRadius !== undefined) { + this.#simulation.force("confine", forceConfine(confinementRadius)); + } + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get alpha(): number { + return this.#simulation.alpha(); + } + + /** The raw buffer backing positions. SharedArrayBuffer when available. */ + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#positionBuffer.raw; + } + + /** Node IDs in buffer order. Sent once to the main thread on creation. */ + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + /** + * Run the simulation for up to `budgetMs` milliseconds. + * Returns true if positions changed. + */ + tick(budgetMs: number): boolean { + if (this.#status === "settled") { + return false; + } + + this.#status = "running"; + const start = performance.now(); + let ticked = false; + + while ( + performance.now() - start < budgetMs && + this.#simulation.alpha() > SETTLE_ALPHA + ) { + this.#simulation.tick(); + if (this.#confinementRadius !== undefined) { + this.#clampPositions(); + } + ticked = true; + } + + if (ticked) { + this.#writePositions(); + } + + if (this.#simulation.alpha() <= SETTLE_ALPHA) { + this.#status = "settled"; + } + + return ticked; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status !== "running") { + this.#status = "running"; + this.#simulation.alpha(0.3).restart().stop(); + } + } + + #writePositions(): void { + for (let idx = 0; idx < this.#nodes.length; idx++) { + const node = this.#nodes[idx]!; + this.#positionBuffer.setPosition(idx, node.x ?? 0, node.y ?? 0); + } + this.#positionBuffer.commit(); + } + + /** Write node `index`'s rgba colour into the buffer (the worker knows the colour). */ + setNodeColor( + index: number, + color: readonly [number, number, number, number], + ): void { + this.#positionBuffer.setColor(index, color); + } + + /** Publish colour writes (bumps the version so the main thread re-uploads). */ + commitColors(): void { + this.#positionBuffer.commit(); + } + + #clampPositions(): void { + const maxR = this.#confinementRadius!; + + for (const node of this.#nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + const dist = Math.hypot(x, y); + const boundary = Math.max(0, maxR * 0.98 - node.radius); + + if (dist > boundary && dist > 0) { + const scale = boundary / dist; + node.x = x * scale; + node.y = y * scale; + } + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forceatlas2-iterate.d.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forceatlas2-iterate.d.ts new file mode 100644 index 00000000000..af44789aa02 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forceatlas2-iterate.d.ts @@ -0,0 +1,23 @@ +/** + * `graphology-layout-forceatlas2` ships its single-iteration primitive as a + * subpath module (`/iterate`) with no bundled type declaration. We drive it + * DIRECTLY -- one iteration per scheduler step over flat Float32Array matrices we + * build and own (rather than the blocking batch `forceAtlas2(graph, n)`, which + * can't stream) -- so we declare just the call shape we use. The matrices are the + * library's documented layout: PPN=10 floats per node, PPE=3 per edge. + * + * This file stays a SCRIPT (no top-level import) so `declare module` shims the + * untyped subpath as an ambient module; the cross-package type reference therefore + * uses an inline `import(...)`, and the export mirrors the package's CommonJS + * `module.exports =` (so `import iterate from ".../iterate"` works under + * esModuleInterop). + */ +declare module "graphology-layout-forceatlas2/iterate" { + const iterate: ( + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + options: import("graphology-layout-forceatlas2").ForceAtlas2Settings, + nodeMatrix: Float32Array, + edgeMatrix: Float32Array, + ) => unknown; + export = iterate; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.test.ts new file mode 100644 index 00000000000..8067f6b5c2c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable id-length */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { INF_DIST, SparseStressSeeder } from "./sparse-stress-seed"; + +const finiteCoords = (x: Float32Array, y: Float32Array) => { + for (let i = 0; i < x.length; i++) { + expect(Number.isFinite(x[i])).toBe(true); + expect(Number.isFinite(y[i])).toBe(true); + } +}; + +const runWithTinyTicks = (seeder: SparseStressSeeder) => { + let previousProgress = 0; + let last = seeder.tick({ maxWork: 1 }); + for (let i = 0; i < 100_000 && !last.done; i++) { + expect(last.progress).toBeGreaterThanOrEqual(previousProgress); + expect(last.progress).toBeGreaterThanOrEqual(0); + expect(last.progress).toBeLessThanOrEqual(1); + previousProgress = last.progress; + last = seeder.tick({ maxWork: 1 }); + } + expect(last.done).toBe(true); + expect(last.progress).toBe(1); + expect(last.result).toBeDefined(); + return last.result!; +}; + +describe("SparseStressSeeder", () => { + it("discovers weak components and fills component nodes", () => { + const seeder = new SparseStressSeeder( + { + n: 5, + src: new Uint32Array([0, 2]), + dst: new Uint32Array([1, 3]), + }, + { epochs: 0, pivotCount: 3, jitter: 0, packComponents: false }, + ); + + const result = seeder.run(); + expect(result.components.count).toBe(3); + expect(Array.from(result.components.sizes)).toEqual([2, 2, 1]); + expect(Array.from(result.components.nodes).sort((a, b) => a - b)).toEqual([ + 0, 1, 2, 3, 4, + ]); + expect(Array.from(result.components.labels)).toEqual([0, 0, 1, 1, 2]); + }); + + it("run() drives all phases to completion", () => { + const seeder = new SparseStressSeeder( + { + n: 4, + src: new Uint32Array([0, 1, 2]), + dst: new Uint32Array([1, 2, 3]), + }, + { epochs: 2, pivotCount: 2, jitter: 0 }, + ); + + const result = seeder.run(); + expect(seeder.phase).toBe("stress-done"); + expect(result.x.length).toBe(4); + expect(result.y.length).toBe(4); + finiteCoords(result.x, result.y); + }); + + it("can be advanced with tiny tick budgets and reports monotonic progress", () => { + const seeder = new SparseStressSeeder( + { + n: 8, + src: new Uint32Array([0, 1, 2, 3, 4, 5, 6]), + dst: new Uint32Array([1, 2, 3, 4, 5, 6, 7]), + }, + { epochs: 2, pivotCount: 3, jitter: 0, pivotsPerEpoch: 2 }, + ); + + const result = runWithTinyTicks(seeder); + finiteCoords(result.x, result.y); + }); + + it("uses pivot row indices during coordinate initialization", () => { + const n = 20; + const src = new Uint32Array(n - 1); + const dst = new Uint32Array(n - 1); + for (let i = 0; i < n - 1; i++) { + src[i] = i; + dst[i] = i + 1; + } + + const result = new SparseStressSeeder( + { n, src, dst }, + { epochs: 0, pivotCount: 4, jitter: 0, packComponents: false }, + ).run(); + + finiteCoords(result.x, result.y); + }); + + it("keeps initial positions when keepInitialPositions is true", () => { + const x = new Float32Array([10, 20]); + const y = new Float32Array([5, 9]); + + const result = new SparseStressSeeder( + { n: 2, src: new Uint32Array([0]), dst: new Uint32Array([1]), x, y }, + { + epochs: 0, + pivotCount: 0, + jitter: 0, + keepInitialPositions: true, + packComponents: false, + }, + ).run(); + + expect(result.x[1]! - result.x[0]!).toBeCloseTo(10, 5); + expect(result.y[1]! - result.y[0]!).toBeCloseTo(4, 5); + }); + + it("wires directedFlow into the stress phase", () => { + const x = new Float32Array([0, 0]); + const y = new Float32Array([0, 0]); + + const result = new SparseStressSeeder( + { n: 2, src: new Uint32Array([0]), dst: new Uint32Array([1]), x, y }, + { + epochs: 1, + pivotCount: 0, + jitter: 0, + edgeWeight: 0, + keepInitialPositions: true, + packComponents: false, + directedFlow: { enabled: true, separation: 4, alpha: 1 }, + }, + ).run(); + + expect(result.y[1]! - result.y[0]!).toBeGreaterThanOrEqual(4 - 1e-5); + }); + + it("omits pivot distances from the result unless requested", () => { + const input = { + n: 4, + src: new Uint32Array([0, 1, 2]), + dst: new Uint32Array([1, 2, 3]), + }; + + const stripped = new SparseStressSeeder(input, { + epochs: 0, + pivotCount: 2, + returnPivotDistances: false, + }).run(); + expect(stripped.pivots.distances.length).toBe(0); + + const retained = new SparseStressSeeder(input, { + epochs: 0, + pivotCount: 2, + returnPivotDistances: true, + }).run(); + expect(retained.pivots.distances.length).toBeGreaterThan(0); + expect(retained.pivots.distances.some((d) => d !== INF_DIST)).toBe(true); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.ts new file mode 100644 index 00000000000..3c8d3af4e57 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-seed.ts @@ -0,0 +1,2707 @@ +/* + * Implementation of sparse stress seeding for ForceAtlas2 or similar force layouts. + * + * References: + * - Stress objective over graph-theoretic target distances: + * Emden R. Gansner, Yehuda Koren, Stephen North, + * "Graph Drawing by Stress Majorization" (2004). + * https://graphviz.org/documentation/GKN04.pdf + * + * - Pairwise stress SGD update, per-pair relaxation cap mu <= 1, and + * exponential eta schedule: + * Jonathan X. Zheng, Samraat Pawar, Dan F. M. Goodman, + * "Graph Drawing by Stochastic Gradient Descent" (2018). + * https://arxiv.org/pdf/1710.04626 + * + * - Sparse/pivot stress idea for avoiding all-pairs stress terms: + * Mark Ortmann, Mirza Klimenta, Ulrik Brandes, + * "A Sparse Stress Model" (2017). + * https://jgaa.info/index.php/jgaa/article/view/paper440 + * See also the authors-of-SGD-adjacent reference implementation notes in + * s_gd2, especially `layout_sparse`. + * https://github.com/jxz12/s_gd2 + * + * - Landmark/Pivot-MDS-style use of distances from a small set of landmarks: + * Vin de Silva, Joshua B. Tenenbaum, + * "Sparse multidimensional scaling using landmark points" (2004), and + * Ulrik Brandes, Christian Pich, + * "Eigensolver Methods for Progressive Multidimensional Scaling of Large Data". + * + * - Directed-flow projection inspiration: WebCola's `flowLayout`, which + * creates separation constraints for directed edges not involved in cycles + * / strongly connected components. + * https://ialab.it.monash.edu/webcola/doc/classes/_layout_.layout.html + * + * - Intended downstream polish: ForceAtlas2 as described by Jacomy, + * Venturini, Heymann, and Bastian (2014). + * https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0098679 + * + */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-bitwise */ +/* eslint-disable id-length */ + +import { Column } from "../collections/column"; + +export const INF_DIST = 0xffff; +const MAX_STORED_DIST = 0xfffe; +const EPS = 1e-9; +const TAU = Math.PI * 2; + +export interface SparseStressSeedInput { + readonly n: number; + readonly src: Uint32Array; + readonly dst: Uint32Array; + + /** Optional output/input coordinate buffers. If provided, they are mutated. */ + readonly x?: Float32Array; + readonly y?: Float32Array; +} + +export interface DirectedFlowOptions { + /** Default false. Adds a light y-axis separation projection for u -> v edges. */ + readonly enabled?: boolean; + + /** Minimum y[v] - y[u] separation. Multiplied by idealEdgeLength. Default 1. */ + readonly separation?: number; + + /** Projection strength. Small values are safer for cyclic/noisy graphs. Default 0.08. */ + readonly alpha?: number; + + /** Run projection every N stress epochs. Default 1. */ + readonly every?: number; + + /** Optional SCC labels. If absent and flow is enabled, labels are computed. */ + readonly sccLabels?: Int32Array; + + /** If true, project even within SCCs. Usually leave false. Default false. */ + readonly includeIntraScc?: boolean; +} + +export interface SparseStressSeedOptions { + /** Number of landmark pivots. Default is auto, capped at 256. */ + readonly pivotCount?: number; + + /** Sparse stress SGD epochs. Default is auto, usually 6 to 12. */ + readonly epochs?: number; + + /** Layout-space length for one graph hop. Default 1. */ + readonly idealEdgeLength?: number; + + /** Edge relaxation weight. Default 1. */ + readonly edgeWeight?: number; + + /** Process at most this many pivots per epoch. Default: all pivots. */ + readonly pivotsPerEpoch?: number; + + /** Initial deterministic jitter, in layout units. Default 0.01. */ + readonly jitter?: number; + + /** Random/hash seed used only for deterministic jitter and tie breaking. Default 1. */ + readonly randomSeed?: number; + + /** Annealing epsilon used in stress SGD. Default 0.1. */ + readonly epsilon?: number; + + /** Keep existing x/y and only run stress from them. Default false. */ + readonly keepInitialPositions?: boolean; + + /** Pack disconnected weak components after stress. Default true. */ + readonly packComponents?: boolean; + + /** Component packing padding in ideal-edge units. Default 4. */ + readonly componentPadding?: number; + + /** Optional directed flow bias. Default disabled. */ + readonly directedFlow?: DirectedFlowOptions; + + /** Validate node ids and buffer lengths. Default true. */ + readonly validate?: boolean; + + /** Return the pivot distance matrix. Default false. */ + readonly returnPivotDistances?: boolean; +} + +class WeakComponents { + readonly count: number; + readonly labels: Int32Array; + readonly offsets: Uint32Array; + readonly nodes: Uint32Array; + readonly sizes: Uint32Array; + readonly seeds: Uint32Array; + + constructor({ + count, + labels, + offsets, + nodes, + sizes, + seeds, + }: { + readonly count: number; + readonly labels: Int32Array; + readonly offsets: Uint32Array; + readonly nodes: Uint32Array; + readonly sizes: Uint32Array; + readonly seeds: Uint32Array; + }) { + this.count = count; + this.labels = labels; + this.offsets = offsets; + this.nodes = nodes; + this.sizes = sizes; + this.seeds = seeds; + } + + static empty(): WeakComponents { + return new WeakComponents({ + count: 0, + labels: new Int32Array(0), + offsets: new Uint32Array(0), + nodes: new Uint32Array(0), + sizes: new Uint32Array(0), + seeds: new Uint32Array(0), + }); + } +} + +class Pivots { + readonly pivots: Uint32Array; + readonly components: Int32Array; + readonly distances: Uint16Array; + readonly diameter: number; + + constructor({ + pivots, + components, + distances, + diameter, + }: { + readonly pivots: Uint32Array; + readonly components: Int32Array; + readonly distances: Uint16Array; + readonly diameter: number; + }) { + this.pivots = pivots; + this.components = components; + this.distances = distances; + this.diameter = diameter; + } + + static unit(): Pivots { + return new Pivots({ + pivots: new Uint32Array(0), + components: new Int32Array(0), + distances: new Uint16Array(0), + diameter: 1, + }); + } +} + +export interface SparseStressSeedResult { + readonly x: Float32Array; + readonly y: Float32Array; + + readonly pivots: Pivots; + + readonly components: WeakComponents; + readonly epochs: number; + + readonly elapsed: number; +} + +export interface CsrGraph { + readonly offsets: Uint32Array; + readonly targets: Uint32Array; + readonly degree: Uint32Array; +} + +export type SparseStressSeederPhase = + | "setup" + | "weak-csr-degree" + | "weak-csr-prefix" + | "weak-csr-fill" + | "components-init" + | "components-scan" + | "pivot-min-fill" + | "pivot-row-fill" + | "pivot-bfs" + | "pivot-select" + | "pivot-done" + | "stress-prepare" + | "stress-init" + | "stress-scc" + | "stress-edges" + | "stress-pivots" + | "stress-flow" + | "stress-pack" + | "stress-done"; + +const SEEDER_PHASE_ORDER: readonly SparseStressSeederPhase[] = [ + "setup", + "weak-csr-degree", + "weak-csr-prefix", + "weak-csr-fill", + "components-init", + "components-scan", + "pivot-min-fill", + "pivot-row-fill", + "pivot-bfs", + "pivot-select", + "pivot-done", + "stress-prepare", + "stress-init", + "stress-scc", + "stress-edges", + "stress-pivots", + "stress-flow", + "stress-pack", + "stress-done", +]; + +export interface SparseStressTickBudget { + /** Approximate unit budget. Edges, nodes, and pair relaxations each cost ~1. */ + readonly maxWork?: number; + + /** Optional wall-clock budget for this tick, in milliseconds. */ + readonly maxMs?: number; +} + +export interface SparseStressProgressReport { + /** Same value as SparseStressTickResult.phase, repeated for convenient logging. */ + readonly phase: SparseStressSeederPhase; + + /** Monotonic overall progress in [0, 1]. */ + readonly progress: number; + + /** Progress inside the current coarse phase bucket in [0, 1]. */ + readonly phaseProgress: number; + + /** Ordinal of the current fine-grained phase in SparseStressSeederPhase order. */ + readonly stageIndex: number; + + /** Total number of fine-grained phases. */ + readonly stageCount: number; + + readonly epoch: number; + readonly epochs: number; + + /** Currently completed/active pivot row, depending on phase. */ + readonly pivotIndex: number; + + /** Final pivot count after pivoting, or requested pivot count while pivoting. */ + readonly pivotCount: number; + + /** Number of pivots selected so far, or final selected count after pivoting. */ + readonly selectedPivotCount: number; + + /** Requested pivot count while the pivot phase exists; useful for UI labels. */ + readonly requestedPivotCount: number; +} + +export interface SparseStressTickResult { + readonly done: boolean; + readonly phase: SparseStressSeederPhase; + readonly progress: number; + + /** Progress inside the current coarse phase bucket in [0, 1]. */ + readonly phaseProgress: number; + + readonly workDone: number; + + readonly elapsedMs: number; + + readonly epoch: number; + readonly epochs: number; + + readonly pivotIndex: number; + readonly pivotCount: number; + + /** Structured progress data for logging/debug UI without poking private fields. */ + readonly report: SparseStressProgressReport; + + readonly x: Float32Array; + readonly y: Float32Array; + + readonly result?: SparseStressSeedResult; +} + +const assertNonNegative = (value: number, name: string) => { + if (value < 0) { + throw new Error(`Expected ${name} to be non-negative, got ${value}`); + } + + return value; +}; + +const assertPositive = (value: number, name: string) => { + if (value <= 0) { + throw new Error(`Expected ${name} to be positive, got ${value}`); + } + + return value; +}; + +const validateInput = ({ n, src, dst, x, y }: SparseStressSeedInput): void => { + if (!Number.isInteger(n) || n < 0) { + throw new Error("n must be a non-negative integer."); + } + + if (src.length !== dst.length) { + throw new Error("src and dst must have the same length."); + } + + if (x && x.length < n) { + throw new Error("x must have length at least n."); + } + + if (y && y.length < n) { + throw new Error("y must have length at least n."); + } +}; + +const defaultPivotCount = (n: number): number => { + if (n <= 1) { + return 0; + } + + if (n < 128) { + return Math.min(n, 16); + } + + return Math.min(n, Math.max(32, Math.min(256, Math.ceil(Math.sqrt(n) * 2)))); +}; + +const defaultEpochCount = (n: number, m: number): number => { + if (n <= 1 || m === 0) { + return 0; + } + if (n < 2000) { + return 12; + } + if (n < 50000) { + return 8; + } + return 5; +}; + +const allocatePivots = ( + components: WeakComponents, + total: number, +): Uint32Array => { + const cN = components.count; + const alloc = new Uint32Array(cN); + if (total <= 0 || cN === 0) { + return alloc; + } + + const order = new Uint32Array(cN); + for (let c = 0; c < cN; c++) { + order[c] = c; + } + order.sort((a, b) => components.sizes[b]! - components.sizes[a]!); + + let remaining = total; + let active = 0; + + for (const c of order) { + const size = components.sizes[c]; + if (size === 0 || remaining === 0) { + continue; + } + + alloc[c] = 1; + remaining -= 1; + active += 1; + } + + if (remaining === 0) { + return alloc; + } + + let totalActiveSize = 0; + for (const c of order) { + if (alloc[c]! > 0) { + totalActiveSize += components.sizes[c]!; + } + } + if (active === 0 || totalActiveSize === 0) { + return alloc; + } + + for (const c of order) { + if (remaining === 0) { + break; + } + const size = components.sizes[c]!; + if (size <= alloc[c]!) { + continue; + } + + const proportional = Math.floor((total * size) / totalActiveSize); + const target = Math.max(alloc[c]!, proportional); + const add = Math.min( + remaining, + Math.max(0, target - alloc[c]!), + size - alloc[c]!, + ); + + alloc[c]! += add; + remaining -= add; + } + + let cursor = 0; + while (remaining > 0) { + const c = order[cursor % order.length]!; + + if (components.sizes[c]! > alloc[c]!) { + alloc[c]! += 1; + remaining -= 1; + } + + cursor += 1; + + if (cursor > order.length * 2 && remaining > 0) { + let changed = false; + + for (const cc of order) { + if (remaining === 0) { + break; + } + + if (components.sizes[cc]! > alloc[cc]!) { + alloc[cc]! += 1; + remaining -= 1; + changed = true; + } + } + + if (!changed) { + break; + } + } + } + + return alloc; +}; + +const now = () => performance.now(); + +const positiveOr = (value: number | undefined, fallback: number): number => + value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; + +const clampInt = (value: number, lo: number, hi: number): number => { + const v = Math.trunc(value); + if (v < lo) { + return lo; + } + + if (v > hi) { + return hi; + } + return v; +}; + +const clampNumber = (value: number, lo: number, hi: number): number => { + if (!Number.isFinite(value)) { + return lo; + } + if (value < lo) { + return lo; + } + if (value > hi) { + return hi; + } + return value; +}; + +const ratio01 = (num: number, den: number): number => + den <= 0 ? 1 : clampNumber(num / den, 0, 1); + +const mixProgress = (base: number, span: number, inner: number): number => + clampNumber(base + span * clampNumber(inner, 0, 1), 0, 1); + +const hashU32 = (x: number): number => { + x >>>= 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d); + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b); + x ^= x >>> 16; + return x >>> 0; +}; + +const hash01 = (x: number): number => hashU32(x) / 0x100000000; + +const recenterComponents = ( + x: Float32Array, + y: Float32Array, + components: WeakComponents, +): void => { + for (let c = 0; c < components.count; c++) { + const start = components.offsets[c]!; + const end = components.offsets[c + 1]!; + const size = end - start; + if (size === 0) { + continue; + } + + let sx = 0; + let sy = 0; + for (let i = start; i < end; i++) { + const v = components.nodes[i]!; + + sx += x[v]!; + sy += y[v]!; + } + + const cx = sx / size; + const cy = sy / size; + for (let i = start; i < end; i++) { + const v = components.nodes[i]!; + x[v]! -= cx; + y[v]! -= cy; + } + } +}; + +const recenterAll = (x: Float32Array, y: Float32Array, n: number): void => { + if (n === 0) { + return; + } + + let sx = 0; + let sy = 0; + for (let i = 0; i < n; i++) { + sx += x[i]!; + sy += y[i]!; + } + const cx = sx / n; + const cy = sy / n; + for (let i = 0; i < n; i++) { + x[i]! -= cx; + y[i]! -= cy; + } +}; + +const packWeakComponents = ( + x: Float32Array, + y: Float32Array, + components: WeakComponents, + padding: number, +): void => { + const cN = components.count; + if (cN <= 1) { + recenterComponents(x, y, components); + return; + } + + const minX = new Float32Array(cN); + const maxX = new Float32Array(cN); + const minY = new Float32Array(cN); + const maxY = new Float32Array(cN); + + for (let c = 0; c < cN; c++) { + minX[c] = Infinity; + minY[c] = Infinity; + maxX[c] = -Infinity; + maxY[c] = -Infinity; + } + + for (let c = 0; c < cN; c++) { + for (let i = components.offsets[c]!; i < components.offsets[c + 1]!; i++) { + const v = components.nodes[i]!; + const xv = x[v]!; + const yv = y[v]!; + if (xv < minX[c]!) { + minX[c] = xv; + } + if (xv > maxX[c]!) { + maxX[c] = xv; + } + if (yv < minY[c]!) { + minY[c] = yv; + } + if (yv > maxY[c]!) { + maxY[c] = yv; + } + } + } + + const order = new Uint32Array(cN); + let totalArea = 0; + + for (let c = 0; c < cN; c++) { + order[c] = c; + const w = Math.max(padding, maxX[c]! - minX[c]! + 2 * padding); + const h = Math.max(padding, maxY[c]! - minY[c]! + 2 * padding); + totalArea += w * h; + } + + order.sort((a, b) => components.sizes[b]! - components.sizes[a]!); + + const targetRowWidth = Math.max(padding, Math.sqrt(totalArea) * 1.25); + const shiftX = new Float32Array(cN); + const shiftY = new Float32Array(cN); + + let cursorX = 0; + let cursorY = 0; + let rowH = 0; + + for (const c of order) { + const w = Math.max(padding, maxX[c]! - minX[c]! + 2 * padding); + const h = Math.max(padding, maxY[c]! - minY[c]! + 2 * padding); + + if (cursorX > 0 && cursorX + w > targetRowWidth) { + cursorX = 0; + cursorY += rowH; + rowH = 0; + } + + shiftX[c] = cursorX + padding - minX[c]!; + shiftY[c] = cursorY + padding - minY[c]!; + + cursorX += w; + if (h > rowH) { + rowH = h; + } + } + + for (let c = 0; c < cN; c++) { + const sx = shiftX[c]!; + const sy = shiftY[c]!; + + for (let i = components.offsets[c]!; i < components.offsets[c + 1]!; i++) { + const v = components.nodes[i]!; + x[v]! += sx; + y[v]! += sy; + } + } + + recenterAll(x, y, components.nodes.length); +}; + +interface SccResult { + readonly labels: Int32Array; + readonly count: number; + readonly sizes: Uint32Array; +} + +const buildDirectedCsr = ( + n: number, + src: Uint32Array, + dst: Uint32Array, + { + reverse, + validate, + }: { readonly reverse: boolean; readonly validate: boolean }, +): CsrGraph => { + const degree = new Uint32Array(n); + const m = src.length; + + for (let e = 0; e < m; e++) { + const a = reverse ? dst[e]! : src[e]!; + const b = reverse ? src[e]! : dst[e]!; + + if (validate && (a >= n || b >= n)) { + throw new Error(`edge ${e} has a node id outside [0, n).`); + } + + if (a === b) { + continue; + } + + degree[a]! += 1; + } + + const offsets = new Uint32Array(n + 1); + for (let i = 0; i < n; i++) { + offsets[i + 1] = offsets[i]! + degree[i]!; + } + + const targets = new Uint32Array(offsets[n]!); + const cursor = offsets.slice(0, n); + + for (let e = 0; e < m; e++) { + const a = reverse ? dst[e]! : src[e]!; + const b = reverse ? src[e]! : dst[e]!; + if (a === b) { + continue; + } + + targets[cursor[a]!] = b; + cursor[a]! += 1; + } + + return { offsets, targets, degree }; +}; + +/** + * Iterative Kosaraju-Sharir SCC labeling used by the optional flow projection. + * It avoids recursion so it is safe for large browser graphs. + */ +const computeSccLabels = ( + n: number, + src: Uint32Array, + dst: Uint32Array, + { validate }: { readonly validate: boolean }, +): SccResult => { + if (validate) { + if (!Number.isInteger(n) || n < 0) { + throw new Error("n must be a non-negative integer."); + } + if (src.length !== dst.length) { + throw new Error("src and dst must have the same length."); + } + } + + const g = buildDirectedCsr(n, src, dst, { reverse: false, validate }); + const gr = buildDirectedCsr(n, src, dst, { reverse: true, validate }); + + const visited = new Uint8Array(n); + const iter = new Uint32Array(n); + const stack = new Uint32Array(n); + const order = new Uint32Array(n); + let orderLen = 0; + + for (let start = 0; start < n; start++) { + if (visited[start]) { + continue; + } + + let sp = 0; + visited[start] = 1; + iter[start] = g.offsets[start]!; + stack[sp] = start; + sp += 1; + + while (sp > 0) { + const u = stack[sp - 1]!; + let p = iter[u]!; + const end = g.offsets[u + 1]!; + + while (p < end && visited[g.targets[p]!]) { + p += 1; + } + iter[u] = p; + + if (p < end) { + const v = g.targets[p]!; + iter[u] = p + 1; + + if (!visited[v]) { + visited[v] = 1; + iter[v] = g.offsets[v]!; + stack[sp] = v; + sp += 1; + } + } else { + sp -= 1; + order[orderLen] = u; + orderLen += 1; + } + } + } + + const labels = new Int32Array(n); + labels.fill(-1); + const sizes: number[] = []; + let count = 0; + + for (let oi = orderLen - 1; oi >= 0; oi--) { + const start = order[oi]!; + if (labels[start] !== -1) { + continue; + } + + let sp = 0; + let size = 0; + labels[start] = count; + stack[sp] = start; + sp += 1; + + while (sp > 0) { + sp -= 1; + const u = stack[sp]!; + size++; + for (let p = gr.offsets[u]!; p < gr.offsets[u + 1]!; p++) { + const v = gr.targets[p]!; + if (labels[v] === -1) { + labels[v] = count; + stack[sp] = v; + sp += 1; + } + } + } + + sizes.push(size); + count++; + } + + return { labels, count, sizes: Uint32Array.from(sizes) }; +}; + +/** + * One pairwise stress-SGD relaxation. + * For a term w_ij (||x_i - x_j|| - d_ij)^2, move the endpoints symmetrically + * along their current separation vector. The `mu = min(w * eta, 1)` cap and + * the half-step endpoint update follow the SGD formulation in + * Zheng/Pawar/Goodman, "Graph Drawing by Stochastic Gradient Descent". + */ +const relaxPair = ( + x: Float32Array, + y: Float32Array, + i: number, + j: number, + ideal: number, + weight: number, + eta: number, +): void => { + const dx = x[i]! - x[j]!; + const dy = y[i]! - y[j]!; + const len2 = dx * dx + dy * dy + EPS; + const len = Math.sqrt(len2); + + let mu = weight * eta; + if (mu > 1) { + mu = 1; + } + if (mu <= 0) { + return; + } + + const s = (mu * 0.5 * (len - ideal)) / len; + const mx = s * dx; + const my = s * dy; + + x[i]! -= mx; + y[i]! -= my; + x[j]! += mx; + y[j]! += my; +}; + +/** + * Exponential annealing schedule for stress SGD. + * This follows the schedule shape used by Zheng/Pawar/Goodman: start with an + * eta large enough that low-weight long-distance terms can move, then decay + * toward epsilon so late epochs behave like small local refinements. + */ +const etaAt = ( + epoch: number, + epochs: number, + diameter: number, + epsilon = 0.1, +): number => { + if (epochs <= 1) { + return epsilon; + } + const d = Math.max(1, diameter); + const etaMax = d * d; + const etaMin = Math.max(EPS, epsilon); + return etaMax * Math.exp(Math.log(etaMin / etaMax) * (epoch / (epochs - 1))); +}; + +class CsrPhase { + #n: number; + #validate: boolean; + + #degree: Uint32Array; + #offsets: Uint32Array; + #targets: Uint32Array; + #cursor: Uint32Array; + + #edgeCursor = 0; + #nodeCursor = 0; + #prefixTotal = 0; + + #phase: "degree" | "prefix" | "fill" | "done" = "degree"; + + #result: CsrGraph | undefined; + + constructor(n: number, { validate }: { readonly validate: boolean }) { + this.#n = n; + + this.#degree = new Uint32Array(n); + this.#offsets = new Uint32Array(n + 1); + this.#targets = new Uint32Array(0); + this.#cursor = new Uint32Array(n); + + this.#validate = validate; + } + + #computeDegree(src: Uint32Array, dst: Uint32Array, budget: number) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const edge = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[edge]!; + const v = dst[edge]!; + + if (this.#validate && (u >= this.#n || v >= this.#n)) { + throw new Error(`edge ${edge} has a node id outside [0, n).`); + } + + if (u !== v) { + this.#degree[u]! += 1; + this.#degree[v]! += 1; + } + + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#nodeCursor = 0; + this.#prefixTotal = 0; + this.#phase = "prefix"; + } + + return work; + } + + #computePrefix(budget: number) { + let work = 0; + if (this.#nodeCursor === 0) { + this.#offsets[0] = 0; + } + + while (this.#nodeCursor < this.#n && work < budget) { + this.#prefixTotal += this.#degree[this.#nodeCursor]!; + this.#offsets[this.#nodeCursor + 1] = this.#prefixTotal; + + this.#nodeCursor += 1; + work += 1; + } + + if (this.#nodeCursor >= this.#n) { + this.#targets = new Uint32Array(this.#offsets[this.#n]!); + this.#cursor = this.#offsets.slice(0, this.#n); + + this.#edgeCursor = 0; + this.#phase = "fill"; + } + + return work; + } + + #computeFill(src: Uint32Array, dst: Uint32Array, budget: number) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const edge = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[edge]!; + const v = dst[edge]!; + + if (u !== v) { + this.#targets[this.#cursor[u]!] = v; + this.#targets[this.#cursor[v]!] = u; + + this.#cursor[u]! += 1; + this.#cursor[v]! += 1; + } + + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#phase = "done"; + + this.#result = { + offsets: this.#offsets, + targets: this.#targets, + degree: this.#degree, + }; + } + + return work; + } + + step(src: Uint32Array, dst: Uint32Array, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "degree": + work += this.#computeDegree(src, dst, remaining); + break; + case "prefix": + work += this.#computePrefix(remaining); + break; + case "fill": + work += this.#computeFill(src, dst, remaining); + break; + case "done": + return work; + } + } + + return work; + } + + progress(edgeCount: number): number { + switch (this.#phase) { + case "degree": + return mixProgress(0, 1 / 3, ratio01(this.#edgeCursor, edgeCount)); + case "prefix": + return mixProgress(1 / 3, 1 / 3, ratio01(this.#nodeCursor, this.#n)); + case "fill": + return mixProgress(2 / 3, 1 / 3, ratio01(this.#edgeCursor, edgeCount)); + case "done": + return 1; + } + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +class WeakComponentsPhase { + readonly #n: number; + + readonly #labels: Int32Array; + readonly #queue: Uint32Array; + readonly #nodes: Uint32Array; + + readonly #offsets: Column; + readonly #sizes: Column; + readonly #seeds: Column; + + #nodeCursor = 0; + + #scan = 0; + #count = 0; + #nodeWrite = 0; + #active = false; + #head = 0; + #tail = 0; + #currentU = -1; + #neighborP = 0; + #neighborEnd = 0; + #best = 0; + #bestDegree = 0; + #size = 0; + + #phase: "init" | "scan" | "done" = "init"; + #result: WeakComponents | undefined; + + constructor(n: number) { + this.#n = n; + + this.#labels = new Int32Array(n); + this.#queue = new Uint32Array(n); + this.#nodes = new Uint32Array(n); + + this.#offsets = new Column(Uint32Array, n); + this.#offsets.push(0); + + this.#sizes = new Column(Uint32Array, n); + this.#seeds = new Column(Uint32Array, n); + } + + #computeInit(budget: number) { + let work = 0; + + while (this.#nodeCursor < this.#n && work < budget) { + this.#labels[this.#nodeCursor] = -1; + this.#nodeCursor += 1; + + work += 1; + } + + if (this.#nodeCursor >= this.#n) { + this.#phase = "scan"; + } + + return work; + } + + #computeScan(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + if (!this.#active) { + while ( + this.#scan < this.#n && + this.#labels[this.#scan] !== -1 && + work < budget + ) { + this.#scan += 1; + work += 1; + } + + if (work >= budget) { + break; + } + + if (this.#scan >= this.#n) { + this.#result = new WeakComponents({ + count: this.#count, + labels: this.#labels, + offsets: this.#offsets.subarray().view, + nodes: this.#nodes, + sizes: this.#sizes.subarray().view, + seeds: this.#seeds.subarray().view, + }); + this.#phase = "done"; + + break; + } + + const start = this.#scan; + this.#labels[start] = this.#count; + this.#head = 0; + this.#tail = 1; + this.#queue[0] = start; + this.#best = start; + this.#bestDegree = csr.degree[start]!; + this.#size = 0; + this.#currentU = -1; + this.#active = true; + } + + if (this.#currentU < 0) { + if (this.#head >= this.#tail) { + this.#sizes.push(this.#size); + this.#seeds.push(this.#best); + this.#offsets.push(this.#nodeWrite); + + this.#count += 1; + this.#active = false; + continue; + } + + const u = this.#queue[this.#head]!; + this.#head += 1; + this.#size += 1; + this.#nodes[this.#nodeWrite] = u; + this.#nodeWrite += 1; + + const degree = csr.degree[u]!; + if ( + degree > this.#bestDegree || + (degree === this.#bestDegree && u < this.#best) + ) { + this.#best = u; + this.#bestDegree = degree; + } + + this.#currentU = u; + this.#neighborP = csr.offsets[u]!; + this.#neighborEnd = csr.offsets[u + 1]!; + } + + while (this.#neighborP < this.#neighborEnd && work < budget) { + const v = csr.targets[this.#neighborP]!; + this.#neighborP += 1; + + if (this.#labels[v] === -1) { + this.#labels[v] = this.#count; + + this.#queue[this.#tail] = v; + this.#tail += 1; + } + + work += 1; + } + + if (this.#neighborP >= this.#neighborEnd) { + this.#currentU = -1; + } + } + + return work; + } + + step(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "init": + work += this.#computeInit(remaining); + break; + case "scan": + work += this.#computeScan(csr, remaining); + break; + case "done": + return work; + } + } + + return work; + } + + progress(): number { + switch (this.#phase) { + case "init": + return mixProgress(0, 0.15, ratio01(this.#nodeCursor, this.#n)); + case "scan": + return mixProgress(0.15, 0.85, ratio01(this.#nodeWrite, this.#n)); + case "done": + return 1; + } + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +class PivotPhase { + readonly #n: number; + readonly #components: WeakComponents; + readonly #randomSeed: number; + + #requestedPivotCount = 0; + + #alloc: Uint32Array; + #pivotsOut: Uint32Array; + #componentsOut: Int32Array; + #distancesOut: Uint16Array; + #minPivotDist: Uint16Array; + #queue: Uint32Array; + + #k = 0; + #diameter = 1; + + #component = 0; + #local = 0; + #want = 0; + #componentStart = 0; + #componentEnd = 0; + #current = 0; + #tieSalt = 0; + #fillCursor = 0; + #rowFillCursor = 0; + #bfsHead = 0; + #bfsTail = 0; + #bfsCurrentU = -1; + #bfsNeighborP = 0; + #bfsNeighborEnd = 0; + #bfsMaxD = 0; + #selectCursor = 0; + #farthest = 0; + #farthestScore = -1; + + #phase: "min-fill" | "row-fill" | "bfs" | "select" | "done" = "min-fill"; + #result: Pivots | undefined; + + constructor( + n: number, + { + components, + count, + randomSeed, + }: { + readonly components: WeakComponents; + readonly count?: number; + readonly randomSeed: number; + }, + ) { + this.#n = n; + this.#components = components; + this.#randomSeed = randomSeed; + this.#requestedPivotCount = clampInt( + count ?? defaultPivotCount(n), + 0, + this.#n, + ); + + this.#alloc = allocatePivots(components, this.#requestedPivotCount); + this.#pivotsOut = new Uint32Array(this.#requestedPivotCount); + this.#componentsOut = new Int32Array(this.#requestedPivotCount); + this.#distancesOut = new Uint16Array(this.#requestedPivotCount * this.#n); + this.#minPivotDist = new Uint16Array(this.#n); + this.#queue = new Uint32Array(this.#n); + this.#k = 0; + this.#diameter = 1; + this.#component = 0; + + if (this.#requestedPivotCount === 0 || n === 0) { + this.#result = Pivots.unit(); + this.#phase = "done"; + } + + this.#prepareNextComponent(); + } + + #finish() { + this.#result = new Pivots({ + pivots: this.#pivotsOut.slice(0, this.#k), + components: this.#componentsOut.slice(0, this.#k), + distances: this.#distancesOut.slice(0, this.#k * this.#n), + diameter: this.#diameter, + }); + + this.#phase = "done"; + } + + #prepareNextComponent() { + while (this.#component < this.#components.count) { + const want = this.#alloc[this.#component]!; + const size = this.#components.sizes[this.#component]!; + + if (want > 0 && size > 0 && this.#k < this.#requestedPivotCount) { + this.#want = want; + this.#local = 0; + this.#componentStart = this.#components.offsets[this.#component]!; + this.#componentEnd = this.#components.offsets[this.#component + 1]!; + this.#current = this.#components.seeds[this.#component]!; + this.#tieSalt = hashU32( + (this.#randomSeed ^ (this.#component * 0x9e3779b9)) >>> 0, + ); + this.#fillCursor = this.#componentStart; + this.#phase = "min-fill"; + return; + } + + this.#component += 1; + } + + this.#finish(); + } + + #startBfsRow() { + this.#pivotsOut[this.#k] = this.#current; + this.#componentsOut[this.#k] = this.#component; + this.#rowFillCursor = 0; + this.#phase = "row-fill"; + } + + #computeMinFill(budget: number): number { + let work = 0; + + while (this.#fillCursor < this.#componentEnd && work < budget) { + const node = this.#components.nodes[this.#fillCursor]!; + this.#fillCursor += 1; + + this.#minPivotDist[node] = INF_DIST; + work += 1; + } + + if (this.#fillCursor >= this.#componentEnd) { + this.#startBfsRow(); + } + + return work; + } + + #computeRowFill(budget: number): number { + const rowBase = this.#k * this.#n; + let work = 0; + + while (this.#rowFillCursor < this.#n && work < budget) { + this.#distancesOut[rowBase + this.#rowFillCursor] = INF_DIST; + this.#rowFillCursor += 1; + + work += 1; + } + + if (this.#rowFillCursor >= this.#n) { + this.#distancesOut[rowBase + this.#current] = 0; + this.#bfsHead = 0; + this.#bfsTail = 1; + this.#queue[0] = this.#current; + this.#bfsCurrentU = -1; + this.#bfsMaxD = 0; + this.#phase = "bfs"; + } + + return work; + } + + #computeBfs(csr: CsrGraph, budget: number): number { + let work = 0; + const rowBase = this.#k * this.#n; + + while (work < budget) { + if (this.#bfsCurrentU < 0) { + if (this.#bfsHead >= this.#bfsTail) { + if (this.#bfsMaxD > this.#diameter) { + this.#diameter = this.#bfsMaxD; + } + + this.#selectCursor = this.#componentStart; + this.#farthest = this.#current; + this.#farthestScore = -1; + this.#tieSalt = hashU32((this.#tieSalt + this.#local + 1) >>> 0); + this.#phase = "select"; + break; + } + + const u = this.#queue[this.#bfsHead]!; + const du = this.#distancesOut[rowBase + u]!; + + this.#bfsHead += 1; + this.#bfsCurrentU = u; + + if (du >= MAX_STORED_DIST) { + this.#bfsNeighborP = 0; + this.#bfsNeighborEnd = 0; + } else { + this.#bfsNeighborP = csr.offsets[u]!; + this.#bfsNeighborEnd = csr.offsets[u + 1]!; + } + } + + const u = this.#bfsCurrentU; + const du = this.#distancesOut[rowBase + u]!; + const nd = du + 1; + + while (this.#bfsNeighborP < this.#bfsNeighborEnd && work < budget) { + const v = csr.targets[this.#bfsNeighborP]!; + this.#bfsNeighborP += 1; + + if (this.#distancesOut[rowBase + v] === INF_DIST) { + this.#distancesOut[rowBase + v] = nd; + if (nd > this.#bfsMaxD) { + this.#bfsMaxD = nd; + } + + this.#queue[this.#bfsTail] = v; + this.#bfsTail += 1; + } + + work += 1; + } + + if (this.#bfsNeighborP >= this.#bfsNeighborEnd) { + this.#bfsCurrentU = -1; + } + } + + return work; + } + + #computeSelect(budget: number): number { + const rowBase = this.#k * this.#n; + let work = 0; + + while (this.#selectCursor < this.#componentEnd && work < budget) { + const v = this.#components.nodes[this.#selectCursor]!; + this.#selectCursor += 1; + + const d = this.#distancesOut[rowBase + v]!; + if (d < this.#minPivotDist[v]!) { + this.#minPivotDist[v] = d; + } + + const md = this.#minPivotDist[v]!; + if (md !== INF_DIST) { + const score = md * 1024 + (hashU32((v ^ this.#tieSalt) >>> 0) & 1023); + if (score > this.#farthestScore) { + this.#farthestScore = score; + this.#farthest = v; + } + } + + work += 1; + } + + if (this.#selectCursor >= this.#componentEnd) { + const previous = this.#current; + this.#k += 1; + this.#local += 1; + + if ( + this.#k >= this.#requestedPivotCount || + this.#local >= this.#want || + this.#farthest === previous || + this.#farthestScore <= 0 + ) { + this.#component += 1; + this.#prepareNextComponent(); + } else { + this.#current = this.#farthest; + this.#startBfsRow(); + } + } + + return work; + } + + step(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + + switch (this.#phase) { + case "min-fill": + work += this.#computeMinFill(remaining); + break; + case "row-fill": + work += this.#computeRowFill(remaining); + break; + case "bfs": + work += this.#computeBfs(csr, remaining); + break; + case "select": + work += this.#computeSelect(remaining); + break; + case "done": + return work; + } + } + + return work; + } + + progress(): number { + if (this.#requestedPivotCount <= 0) { + return 1; + } + + let rowProgress = 0; + switch (this.#phase) { + case "min-fill": + rowProgress = mixProgress( + 0, + 0.1, + ratio01( + this.#fillCursor - this.#componentStart, + this.#componentEnd - this.#componentStart, + ), + ); + break; + case "row-fill": + rowProgress = mixProgress( + 0.1, + 0.15, + ratio01(this.#rowFillCursor, this.#n), + ); + break; + case "bfs": + rowProgress = mixProgress( + 0.25, + 0.55, + ratio01(this.#bfsHead, Math.max(1, this.#bfsTail)), + ); + break; + case "select": + rowProgress = mixProgress( + 0.8, + 0.2, + ratio01( + this.#selectCursor - this.#componentStart, + this.#componentEnd - this.#componentStart, + ), + ); + break; + case "done": + return 1; + } + + return ratio01(this.#k + rowProgress, this.#requestedPivotCount); + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } + + get k() { + return this.#k; + } + + get requestedPivotCount() { + return this.#requestedPivotCount; + } +} + +class StressPhase { + readonly #n: number; + readonly #jitter: number; + readonly #idealEdgeLength: number; + readonly #randomSeed: number; + readonly #keepInitialPositions: boolean; + readonly #flow?: DirectedFlowOptions; + readonly #validate: boolean; + readonly #epochs: number; + readonly #epsilon: number; + readonly #pivotsPerEpoch: number | undefined; + readonly #edgeWeight: number; + readonly #shouldPackComponents: boolean; + readonly #componentPadding: number; + + readonly #x: Float32Array; + readonly #y: Float32Array; + + // Coordinate initialization state. + #initFirst4: Int32Array | undefined; + #initComponent = 0; + #initNodeCursor = 0; + + // Optional directed-flow/SCC state. + #sccLabels: Int32Array | undefined; + + #epoch = 0; + #eta = 0; + #edgeCursor = 0; + #pivotBatchCursor = 0; + #pivotNodeCursor = 0; + #pivotNodeEnd = 0; + #pivotIndex = 0; + #pivotStart = 0; + + #phase: + | "prepare" + | "init" + | "scc" + | "pack" + | "edges" + | "pivots" + | "flow" + | "done" = "prepare"; + + constructor( + n: number, + x: Float32Array, + y: Float32Array, + { + jitter, + idealEdgeLength, + randomSeed, + keepInitialPositions, + validate, + epochs, + epsilon, + pivotsPerEpoch, + edgeWeight, + shouldPackComponents, + componentPadding, + flow, + }: { + readonly jitter: number; + readonly idealEdgeLength: number; + readonly randomSeed: number; + readonly keepInitialPositions: boolean; + readonly validate: boolean; + readonly epochs: number; + readonly epsilon: number; + readonly pivotsPerEpoch: number | undefined; + readonly edgeWeight: number; + readonly shouldPackComponents: boolean; + readonly componentPadding: number; + readonly flow?: DirectedFlowOptions; + }, + ) { + this.#n = n; + this.#x = x; + this.#y = y; + + this.#jitter = jitter; + this.#idealEdgeLength = idealEdgeLength; + this.#randomSeed = randomSeed; + this.#keepInitialPositions = keepInitialPositions; + this.#validate = validate; + this.#epochs = epochs; + this.#epsilon = epsilon; + this.#pivotsPerEpoch = pivotsPerEpoch; + this.#edgeWeight = edgeWeight; + this.#shouldPackComponents = shouldPackComponents; + this.#componentPadding = componentPadding; + this.#flow = flow; + } + + #prepareCoordinates(components: WeakComponents, pivots: Pivots): number { + const first4 = new Int32Array(components.count * 4); + first4.fill(-1); + + for (let p = 0; p < pivots.pivots.length; p++) { + const c = pivots.components[p]!; + const base = c * 4; + + for (let slot = 0; slot < 4; slot++) { + if (first4[base + slot] === -1) { + first4[base + slot] = p; + break; + } + } + } + + this.#initFirst4 = first4; + this.#initComponent = 0; + this.#initNodeCursor = components.count > 0 ? components.offsets[0]! : 0; + this.#phase = "init"; + + return 1; + } + + #finishCoordinates(pivots: Pivots) { + if (this.#flow?.enabled && this.#flow.sccLabels) { + this.#sccLabels = this.#flow.sccLabels; + } + + if ( + this.#flow?.enabled && + !this.#flow.includeIntraScc && + !this.#sccLabels + ) { + this.#phase = "scc"; + } else { + this.#prepareStressOrPack(pivots); + } + } + + #computeCoordinates( + components: WeakComponents, + pivots: Pivots, + budget: number, + ) { + const jitterScale = this.#idealEdgeLength * this.#jitter; + let work = 0; + + const distance = (d: number) => (d === INF_DIST ? 0 : d); + const first4 = this.#initFirst4!; + + while (this.#initComponent < components.count && work < budget) { + const end = components.offsets[this.#initComponent + 1]!; + const size = end - components.offsets[this.#initComponent]!; + const base = this.#initComponent * 4; + + const p0 = first4[base]!; + const p1 = first4[base + 1]!; + const p2 = first4[base + 2]!; + const p3 = first4[base + 3]!; + + while (this.#initNodeCursor < end && work < budget) { + const v = components.nodes[this.#initNodeCursor]!; + + if (this.#keepInitialPositions) { + if (jitterScale > 0) { + this.#x[v]! += + (hash01((v ^ (this.#randomSeed * 0x9e3779b1)) >>> 0) - 0.5) * + jitterScale; + this.#y[v]! += + (hash01(((v + 0x27d4eb2d) ^ this.#randomSeed) >>> 0) - 0.5) * + jitterScale; + } + + this.#initNodeCursor += 1; + work += 1; + continue; + } + + let px = 0; + let py = 0; + + if (p0 >= 0 && p1 >= 0) { + const d0 = pivots.distances[p0 * this.#n + v]!; + const d1 = pivots.distances[p1 * this.#n + v]!; + + px = (distance(d0) - distance(d1)) * this.#idealEdgeLength; + } else if (p0 >= 0) { + const d0 = distance(pivots.distances[p0 * this.#n + v]!); + const angle = hash01((v ^ this.#randomSeed) >>> 0) * TAU; + + px = d0 * this.#idealEdgeLength * Math.cos(angle); + py = d0 * this.#idealEdgeLength * Math.sin(angle); + } else { + const local = + this.#initNodeCursor - components.offsets[this.#initComponent]!; + const angle = hash01((v ^ this.#randomSeed) >>> 0) * TAU; + + const r = Math.sqrt(local + 1) * this.#idealEdgeLength; + + px = r * Math.cos(angle); + py = r * Math.sin(angle); + } + + if (p2 >= 0 && p3 >= 0) { + const d2 = distance(pivots.distances[p2 * this.#n + v]!); + const d3 = distance(pivots.distances[p3 * this.#n + v]!); + + py = (distance(d2) - distance(d3)) * this.#idealEdgeLength; + } else if (p2 >= 0 && p0 >= 0 && p1 >= 0) { + const d0 = distance(pivots.distances[p0 * this.#n + v]!); + const d1 = distance(pivots.distances[p1 * this.#n + v]!); + const d2 = distance(pivots.distances[p2 * this.#n + v]!); + + py = (d2 - 0.5 * (d0 + d1)) * this.#idealEdgeLength; + } else if (p1 >= 0) { + const angle = + hash01(((v + 0x85ebca6b) ^ this.#randomSeed) >>> 0) * TAU; + + py = + Math.sin(angle) * + Math.max( + this.#idealEdgeLength, + Math.sqrt(size) * 0.01 * this.#idealEdgeLength, + ); + } + + if (jitterScale > 0) { + px += + (hash01((v ^ (this.#randomSeed * 0x9e3779b1)) >>> 0) - 0.5) * + jitterScale; + py += + (hash01(((v + 0x27d4eb2d) ^ this.#randomSeed) >>> 0) - 0.5) * + jitterScale; + } + + this.#x[v] = px; + this.#y[v] = py; + this.#initNodeCursor += 1; + work += 1; + } + + if (this.#initNodeCursor >= end) { + this.#initComponent += 1; + this.#initNodeCursor = + this.#initComponent < components.count + ? components.offsets[this.#initComponent]! + : 0; + } + } + + if (this.#initComponent >= components.count) { + this.#finishCoordinates(pivots); + } + + return work; + } + + #computeScc(src: Uint32Array, dst: Uint32Array, pivots: Pivots) { + // SCC computation is only needed for optional directed-flow projection. + // Pass directedFlow.sccLabels if you want to avoid this one-shot pass in a + // tight frame budget. The algorithm itself is iterative Kosaraju-Sharir. + this.#sccLabels = computeSccLabels(this.#n, src, dst, { + validate: this.#validate, + }).labels; + + this.#prepareStressOrPack(pivots); + return 1; + } + + #prepareStressOrPack(pivots: Pivots) { + this.#epoch = 0; + + if (this.#epochs <= 0) { + this.#phase = "pack"; + return; + } + + this.#beginEpoch(pivots); + } + + #resolvedPivotsPerEpoch(pivots: Pivots): number { + const k = pivots.pivots.length; + return clampInt(this.#pivotsPerEpoch ?? k, 0, k); + } + + #prepareNextPivot(components: WeakComponents, pivots: Pivots) { + const k = pivots.pivots.length; + const limit = this.#resolvedPivotsPerEpoch(pivots); + + if (k === 0 || limit <= 0 || this.#pivotBatchCursor >= limit) { + this.#phase = "flow"; + this.#edgeCursor = 0; + return; + } + + const pIndex = (this.#pivotStart + this.#pivotBatchCursor) % k; + const component = pivots.components[pIndex]!; + + this.#pivotIndex = pIndex; + this.#pivotNodeCursor = components.offsets[component]!; + this.#pivotNodeEnd = components.offsets[component + 1]!; + } + + #beginEpoch(pivots: Pivots) { + this.#eta = etaAt( + this.#epoch, + Math.max(1, this.#epochs), + Math.max(1, pivots.diameter), + this.#epsilon, + ); + this.#edgeCursor = 0; + this.#pivotBatchCursor = 0; + this.#pivotNodeCursor = 0; + this.#pivotNodeEnd = 0; + this.#pivotIndex = 0; + this.#pivotStart = + pivots.pivots.length === 0 + ? 0 + : (this.#epoch * this.#resolvedPivotsPerEpoch(pivots)) % + pivots.pivots.length; + this.#phase = "edges"; + } + + #computeEdges( + src: Uint32Array, + dst: Uint32Array, + components: WeakComponents, + pivots: Pivots, + budget: number, + ) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const e = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[e]!; + const v = dst[e]!; + + if (u !== v) { + relaxPair( + this.#x, + this.#y, + u, + v, + this.#idealEdgeLength, + this.#edgeWeight, + this.#eta, + ); + } + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#phase = "pivots"; + this.#prepareNextPivot(components, pivots); + } + + return work; + } + + #computePivots(components: WeakComponents, pivots: Pivots, budget: number) { + const distances = pivots.distances; + let work = 0; + + while (work < budget) { + const limit = this.#resolvedPivotsPerEpoch(pivots); + if ( + pivots.pivots.length === 0 || + limit <= 0 || + this.#pivotBatchCursor >= limit + ) { + this.#phase = "flow"; + this.#edgeCursor = 0; + break; + } + + const pivot = pivots.pivots[this.#pivotIndex]!; + const rowBase = this.#pivotIndex * this.#n; + + while (this.#pivotNodeCursor < this.#pivotNodeEnd && work < budget) { + const v = components.nodes[this.#pivotNodeCursor]!; + this.#pivotNodeCursor += 1; + const d = distances[rowBase + v]!; + + if (d !== 0 && d !== INF_DIST) { + const ideal = d * this.#idealEdgeLength; + const weight = 1 / (d * d); + + relaxPair(this.#x, this.#y, pivot, v, ideal, weight, this.#eta); + } + work++; + } + + if (this.#pivotNodeCursor >= this.#pivotNodeEnd) { + this.#pivotBatchCursor += 1; + this.#prepareNextPivot(components, pivots); + if (this.#phase !== "pivots") { + break; + } + } + } + + return work; + } + + #computeFlow( + src: Uint32Array, + dst: Uint32Array, + pivots: Pivots, + budget: number, + ) { + const flow = this.#flow; + const m = src.length; + let work = 0; + + if (flow?.enabled) { + const every = Math.max(1, flow.every ?? 1); + if (this.#epoch % every === 0) { + const separation = + this.#idealEdgeLength * positiveOr(flow.separation, 1); + const alpha = clampNumber(flow.alpha ?? 0.08, 0, 1); + const includeIntraScc = flow.includeIntraScc === true; + const labels = this.#sccLabels; + + while (this.#edgeCursor < m && work < budget) { + const e = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[e]!; + const v = dst[e]!; + + if ( + u !== v && + (includeIntraScc || !labels || labels[u] !== labels[v]) + ) { + const gap = this.#y[v]! - this.#y[u]!; + const violation = separation - gap; + if (violation > 0) { + const move = 0.5 * alpha * violation; + this.#y[u]! -= move; + this.#y[v]! += move; + } + } + work++; + } + } else { + this.#edgeCursor = m; + } + } else { + this.#edgeCursor = m; + } + + if (this.#edgeCursor >= m) { + this.#epoch += 1; + if (this.#epoch >= this.#epochs) { + this.#phase = "pack"; + } else { + this.#beginEpoch(pivots); + } + } + + return work; + } + + #computePack(components: WeakComponents) { + if (this.#shouldPackComponents) { + packWeakComponents( + this.#x, + this.#y, + components, + this.#idealEdgeLength * this.#componentPadding, + ); + } else { + recenterAll(this.#x, this.#y, this.#n); + } + + this.#phase = "done"; + return 1; + } + + step( + src: Uint32Array, + dst: Uint32Array, + components: WeakComponents, + pivots: Pivots, + budget: number, + ): number { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "prepare": { + work += this.#prepareCoordinates(components, pivots); + break; + } + case "init": { + work += this.#computeCoordinates(components, pivots, remaining); + break; + } + case "scc": { + work += this.#computeScc(src, dst, pivots); + break; + } + case "pack": { + work += this.#computePack(components); + break; + } + case "edges": { + work += this.#computeEdges(src, dst, components, pivots, remaining); + break; + } + case "pivots": { + work += this.#computePivots(components, pivots, remaining); + break; + } + case "flow": { + work += this.#computeFlow(src, dst, pivots, remaining); + break; + } + case "done": { + return work; + } + } + } + + return work; + } + + progress( + components: WeakComponents, + pivots: Pivots, + edgeCount: number, + ): number { + switch (this.#phase) { + case "prepare": + return 0; + case "init": + return mixProgress( + 0, + 0.08, + ratio01(this.#initNodeCursor, components.nodes.length), + ); + case "scc": + return 0.09; + case "pack": + return 0.98; + case "done": + return 1; + case "edges": + case "pivots": + case "flow": { + if (this.#epochs <= 0) { + return 0.98; + } + + let inEpoch = 0; + if (this.#phase === "edges") { + inEpoch = mixProgress(0, 0.35, ratio01(this.#edgeCursor, edgeCount)); + } else if (this.#phase === "pivots") { + const limit = this.#resolvedPivotsPerEpoch(pivots); + let nodeProgress = 0; + if (limit > 0 && pivots.pivots.length > 0) { + const component = pivots.components[this.#pivotIndex]!; + const start = components.offsets[component] ?? 0; + const end = components.offsets[component + 1] ?? start; + nodeProgress = ratio01(this.#pivotNodeCursor - start, end - start); + } + const pivotProgress = ratio01( + this.#pivotBatchCursor + nodeProgress, + Math.max(1, limit), + ); + inEpoch = mixProgress(0.35, 0.55, pivotProgress); + } else { + inEpoch = mixProgress(0.9, 0.1, ratio01(this.#edgeCursor, edgeCount)); + } + + return mixProgress( + 0.08, + 0.9, + ratio01(this.#epoch + inEpoch, this.#epochs), + ); + } + } + } + + get phase() { + return this.#phase; + } + + get epoch() { + return this.#epoch; + } + + get epochs() { + return this.#epochs; + } + + get currentPivotIndex() { + return this.#pivotIndex; + } +} + +export class SparseStressSeeder { + readonly #n: number; + readonly #src: Uint32Array; + readonly #dst: Uint32Array; + readonly #options: SparseStressSeedOptions; + readonly #validate: boolean; + + readonly #randomSeed: number; + + #phase: SparseStressSeederPhase = "setup"; + #done = false; + #result: SparseStressSeedResult | undefined; + #lastProgress = 0; + + // Lightweight snapshots used after heavy phase objects have been released. + // In particular, PivotPhase owns a full pivot-distance scratch matrix, so it + // should not be kept solely for UI reporting once pivoting is complete. + #requestedPivotCountForReport = 0; + #selectedPivotCountForReport = 0; + + startedAt = 0; + + #x: Float32Array; + #y: Float32Array; + + #components: WeakComponents | undefined; + #csr: CsrGraph | undefined; + #pivots: Pivots | undefined; + + #csrPhase: CsrPhase | undefined; + #componentsPhase: WeakComponentsPhase | undefined; + #pivotPhase: PivotPhase | undefined; + + #stress: StressPhase; + + constructor( + input: SparseStressSeedInput, + options: SparseStressSeedOptions = {}, + ) { + this.#n = input.n; + this.#src = input.src; + this.#dst = input.dst; + this.#options = options; + this.#validate = options.validate ?? true; + + const idealEdgeLength = assertNonNegative( + options.idealEdgeLength ?? 1, + "idealEdgeLength", + ); + const edgeWeight = assertNonNegative(options.edgeWeight ?? 1, "edgeWeight"); + const randomSeed = options.randomSeed ?? 1; + const jitter = options.jitter ?? 0.01; + const epsilon = assertNonNegative(options.epsilon ?? 0.1, "epsilon"); + const shouldPackComponents = options.packComponents ?? true; + const componentPadding = assertNonNegative( + options.componentPadding ?? 4, + "componentPadding", + ); + + this.#x = input.x ?? new Float32Array(input.n); + this.#y = input.y ?? new Float32Array(input.n); + this.#randomSeed = randomSeed; + + const epochs = clampInt( + options.epochs ?? defaultEpochCount(this.#n, this.#src.length), + 0, + 1000000, + ); + + this.#stress = new StressPhase(this.#n, this.#x, this.#y, { + jitter, + idealEdgeLength, + randomSeed, + keepInitialPositions: options.keepInitialPositions ?? false, + validate: this.#validate, + epochs, + epsilon, + pivotsPerEpoch: options.pivotsPerEpoch, + edgeWeight, + shouldPackComponents, + componentPadding, + flow: options.directedFlow, + }); + } + + #finish(components: WeakComponents, pivots: Pivots, epochs: number): void { + const elapsedMs = this.startedAt === 0 ? 0 : now() - this.startedAt; + const resultPivots = this.#options.returnPivotDistances + ? pivots + : new Pivots({ + pivots: pivots.pivots, + components: pivots.components, + distances: new Uint16Array(0), + diameter: pivots.diameter, + }); + + const result: SparseStressSeedResult = { + x: this.#x, + y: this.#y, + pivots: resultPivots, + components, + epochs, + elapsed: elapsedMs, + }; + + this.#result = result; + this.#phase = "stress-done"; + this.#done = true; + } + + #setup(): number { + if (this.#validate) { + validateInput({ + n: this.#n, + src: this.#src, + dst: this.#dst, + x: this.#x, + y: this.#y, + }); + } + + if (this.#n === 0) { + this.#components = WeakComponents.empty(); + this.#pivots = Pivots.unit(); + this.#requestedPivotCountForReport = 0; + this.#selectedPivotCountForReport = 0; + + this.#finish(this.#components, this.#pivots, 0); + return 0; + } + + this.#csrPhase = new CsrPhase(this.#n, { validate: this.#validate }); + this.#phase = "weak-csr-degree"; + + return 0; + } + + #step(budget: number): number { + switch (this.#phase) { + case "setup": + return this.#setup(); + case "weak-csr-degree": + case "weak-csr-prefix": + case "weak-csr-fill": { + const phase = this.#csrPhase!; + const done = phase.step(this.#src, this.#dst, budget); + + if (phase.phase === "done") { + // Flush the result back to our main class, `done` means that the result has been populated. + this.#csr = phase.result!; + + this.#componentsPhase = new WeakComponentsPhase(this.#n); + + // Keep the completed CSR phase mounted: it is lightweight report state + // and shares the CSR arrays with #csr rather than duplicating them. + this.#phase = "components-init"; + } else { + this.#phase = `weak-csr-${phase.phase}`; + } + + return done; + } + + case "components-init": + case "components-scan": { + const phase = this.#componentsPhase!; + const done = phase.step(this.#csr!, budget); + + if (phase.phase === "done") { + // Flush the result back to our main class, `done` means that the result has been populated. + this.#components = phase.result!; + + this.#pivotPhase = new PivotPhase(this.#n, { + randomSeed: this.#randomSeed, + components: this.#components, + count: this.#options.pivotCount, + }); + this.#requestedPivotCountForReport = + this.#pivotPhase.requestedPivotCount; + // Keep the completed components phase mounted for reporting. It owns a + // queue, which is fine to keep around. + this.#phase = `pivot-${this.#pivotPhase.phase}`; + } else { + this.#phase = `components-${phase.phase}`; + } + + return done; + } + case "pivot-min-fill": + case "pivot-row-fill": + case "pivot-bfs": + case "pivot-select": + case "pivot-done": { + const phase = this.#pivotPhase!; + const done = phase.step(this.#csr!, budget); + + if (phase.phase === "done") { + // Flush the result back to our main class, `done` means that the result has been populated. + this.#pivots = phase.result!; + this.#selectedPivotCountForReport = this.#pivots.pivots.length; + this.#requestedPivotCountForReport = phase.requestedPivotCount; + + this.#phase = `stress-prepare`; + // We unmount the pivot phase, and put it's state used for reporting into a locals, + // reason being that pivoting allocates relatively speaking large scratch buffers. + // That are best dropped early. + this.#pivotPhase = undefined; + } else { + this.#phase = `pivot-${phase.phase}`; + } + + return done; + } + case "stress-prepare": + case "stress-init": + case "stress-scc": + case "stress-edges": + case "stress-pivots": + case "stress-flow": + case "stress-pack": + case "stress-done": { + const previousPhase = this.#phase; + const done = this.#stress.step( + this.#src, + this.#dst, + this.#components!, + this.#pivots!, + budget, + ); + + this.#phase = `stress-${this.#stress.phase}`; + if (this.#phase === "stress-done" && previousPhase !== "stress-done") { + this.#finish(this.#components!, this.#pivots!, this.#stress.epochs); + } + return done; + } + } + } + + #rawProgressEstimate(): number { + if (this.#done) { + return 1; + } + + switch (this.#phase) { + case "setup": + return 0; + case "weak-csr-degree": + case "weak-csr-prefix": + case "weak-csr-fill": + return mixProgress( + 0, + 0.18, + this.#csrPhase?.progress(this.#src.length) ?? 0, + ); + case "components-init": + case "components-scan": + return mixProgress(0.18, 0.17, this.#componentsPhase?.progress() ?? 0); + case "pivot-min-fill": + case "pivot-row-fill": + case "pivot-bfs": + case "pivot-select": + case "pivot-done": + return mixProgress(0.35, 0.3, this.#pivotPhase?.progress() ?? 0); + case "stress-prepare": + case "stress-init": + case "stress-scc": + case "stress-edges": + case "stress-pivots": + case "stress-flow": + case "stress-pack": + case "stress-done": + return mixProgress( + 0.65, + 0.35, + this.#stress.progress( + this.#components ?? WeakComponents.empty(), + this.#pivots ?? Pivots.unit(), + this.#src.length, + ), + ); + } + } + + #progressEstimate(): number { + const progress = this.#rawProgressEstimate(); + this.#lastProgress = Math.max( + this.#lastProgress, + clampNumber(progress, 0, this.#done ? 1 : 0.999), + ); + if (this.#done) { + this.#lastProgress = 1; + } + return this.#lastProgress; + } + + #phaseProgressEstimate(): number { + switch (this.#phase) { + case "setup": + return 0; + case "weak-csr-degree": + case "weak-csr-prefix": + case "weak-csr-fill": + return this.#csrPhase?.progress(this.#src.length) ?? 1; + case "components-init": + case "components-scan": + return this.#componentsPhase?.progress() ?? 1; + case "pivot-min-fill": + case "pivot-row-fill": + case "pivot-bfs": + case "pivot-select": + case "pivot-done": + return this.#pivotPhase?.progress() ?? 1; + case "stress-prepare": + case "stress-init": + case "stress-scc": + case "stress-edges": + case "stress-pivots": + case "stress-flow": + case "stress-pack": + case "stress-done": + return this.#stress.progress( + this.#components ?? WeakComponents.empty(), + this.#pivots ?? Pivots.unit(), + this.#src.length, + ); + } + } + + #pivotCountForReport(): number { + if (this.#pivots) { + return this.#pivots.pivots.length; + } + + if (this.#pivotPhase) { + return this.#pivotPhase.requestedPivotCount; + } + + return this.#requestedPivotCountForReport; + } + + #selectedPivotCountForReportValue(): number { + if (this.#pivotPhase) { + return this.#pivotPhase.k; + } + + return ( + (this.#selectedPivotCountForReport || this.#pivots?.pivots.length) ?? 0 + ); + } + + #stageIndexForReport(): number { + const index = SEEDER_PHASE_ORDER.indexOf(this.#phase); + return index === -1 ? 0 : index; + } + + #currentPivotIndexForReport(): number { + if (this.#pivotPhase) { + return this.#pivotPhase.k; + } + + if (this.#phase.startsWith("stress-")) { + return this.#stress.currentPivotIndex; + } + + return this.#selectedPivotCountForReportValue(); + } + + tick(budget: SparseStressTickBudget = {}): SparseStressTickResult { + const maxWork = assertPositive( + Math.floor(budget.maxWork ?? 50_000), + "maxWork", + ); + const maxMs = assertNonNegative(budget.maxMs ?? Infinity, "maxMs"); + + const start = now(); + if (this.startedAt === 0) { + this.startedAt = start; + } + + let workDone = 0; + let zeroWorkTransitions = 0; + + while (!this.#done && workDone < maxWork) { + // We need to make sure that we have done _some_ work + if (workDone > 0 && now() - start >= maxMs) { + break; + } + + const beforePhase = this.#phase; + const did = this.#step(maxWork - workDone); + + if (did > 0) { + workDone += did; + zeroWorkTransitions = 0; + } else { + zeroWorkTransitions++; + if (beforePhase === this.#phase || zeroWorkTransitions > 64) { + break; + } + } + } + + const elapsed = now() - start; + const progress = this.#progressEstimate(); + const phaseProgress = clampNumber(this.#phaseProgressEstimate(), 0, 1); + const pivotIndex = this.#currentPivotIndexForReport(); + const pivotCount = this.#pivotCountForReport(); + const selectedPivotCount = this.#selectedPivotCountForReportValue(); + const stageIndex = this.#stageIndexForReport(); + + const report: SparseStressProgressReport = { + phase: this.#phase, + progress, + phaseProgress, + stageIndex, + stageCount: SEEDER_PHASE_ORDER.length, + epoch: this.#stress.epoch, + epochs: this.#stress.epochs, + pivotIndex, + pivotCount, + selectedPivotCount, + requestedPivotCount: this.#requestedPivotCountForReport, + }; + + return { + done: this.#done, + phase: this.#phase, + progress, + phaseProgress, + workDone, + elapsedMs: elapsed, + epoch: this.#stress.epoch, + epochs: this.#stress.epochs, + pivotIndex, + pivotCount, + report, + x: this.#x, + y: this.#y, + result: this.#result, + }; + } + + run(): SparseStressSeedResult { + if (this.startedAt === 0) { + this.startedAt = now(); + } + + let zeroWorkTransitions = 0; + while (!this.#done) { + const beforePhase = this.#phase; + const did = this.#step(Infinity); + + if (did > 0) { + zeroWorkTransitions = 0; + } else { + zeroWorkTransitions += 1; + if (beforePhase === this.#phase || zeroWorkTransitions > 64) { + throw new Error( + `SparseStressSeeder stalled in phase ${this.#phase}.`, + ); + } + } + } + + return this.#result!; + } + + get result(): SparseStressSeedResult | undefined { + return this.#result; + } + + get phase(): SparseStressSeederPhase { + return this.#phase; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.test.ts new file mode 100644 index 00000000000..7649be978e4 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.test.ts @@ -0,0 +1,323 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { + type Anchor, + type LayoutNode, + measureLayout, + optimizeTopLevel, + relaxOverlaps, +} from "./top-level-layout"; + +/** A regular polygon of `count` equal bubbles at radius `ring` from the origin. */ +function polygon(count: number, ring: number, radius: number): LayoutNode[] { + const nodes: LayoutNode[] = []; + for (let idx = 0; idx < count; idx++) { + const angle = (idx / count) * 2 * Math.PI; + nodes.push({ + x: Math.cos(angle) * ring, + y: Math.sin(angle) * ring, + radius, + }); + } + return nodes; +} + +/** Ring edges (0-1, 1-2, …, n-1-0): a crossing-free cycle for the polygon. */ +function ringEdges(count: number): [number, number][] { + return Array.from( + { length: count }, + (_, idx) => [idx, (idx + 1) % count] as [number, number], + ); +} + +/** Mean-removed displacement of each node from its anchor (translation-invariant). */ +function relativeDisplacements( + nodes: readonly LayoutNode[], + anchors: readonly (Anchor | null)[], +): number[] { + let dxSum = 0; + let dySum = 0; + let anchored = 0; + for (let idx = 0; idx < nodes.length; idx++) { + const anchor = anchors[idx]; + if (!anchor) { + continue; + } + dxSum += nodes[idx]!.x - anchor.x; + dySum += nodes[idx]!.y - anchor.y; + anchored += 1; + } + const meanX = anchored > 0 ? dxSum / anchored : 0; + const meanY = anchored > 0 ? dySum / anchored : 0; + return nodes.map((node, idx) => { + const anchor = anchors[idx]; + if (!anchor) { + return Number.NaN; + } + return Math.hypot(node.x - anchor.x - meanX, node.y - anchor.y - meanY); + }); +} + +describe("optimizeTopLevel", () => { + it("clears an edge forced straight through a huge obstacle bubble", () => { + // A↔B connected, with a HUGE bubble C parked on the A–B line between them + // (C↔D keeps C placeable). This is the "Of Material wraps around Material + // Movement" case: the straight edge pierces the obstacle. + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, // A + { x: 160, y: 0, radius: 10 }, // B + { x: 80, y: 0, radius: 50 }, // C — huge, between A and B + { x: 80, y: 150, radius: 10 }, // D + ]; + const edges: ReadonlyArray = [ + [0, 1], // A–B + [2, 3], // C–D + ]; + + const before = measureLayout(nodes, edges); + optimizeTopLevel(nodes, edges, 12345); + const after = measureLayout(nodes, edges); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] detour ${before.detour.toFixed(2)} → ${after.detour.toFixed(2)}, energy ${before.energy.toFixed(1)} → ${after.energy.toFixed(1)}`, + ); + expect(before.detour).toBeGreaterThan(0.8); // edge pierced the obstacle + expect(after.detour).toBeLessThan(0.2); // optimiser cleared it + expect(after.energy).toBeLessThan(before.energy); + for (const node of nodes) { + expect(Number.isFinite(node.x)).toBe(true); + expect(Number.isFinite(node.y)).toBe(true); + } + }); + + it("uncrosses two crossing edges", () => { + // Square with the two diagonals as edges → they cross. A reposition/swap + // makes them parallel. + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, // A + { x: 100, y: 100, radius: 10 }, // B + { x: 100, y: 0, radius: 10 }, // C + { x: 0, y: 100, radius: 10 }, // D + ]; + const edges: ReadonlyArray = [ + [0, 1], // A–B (diagonal) + [2, 3], // C–D (diagonal) + ]; + + const before = measureLayout(nodes, edges); + optimizeTopLevel(nodes, edges, 777); + const after = measureLayout(nodes, edges); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] crossings ${before.crossings} → ${after.crossings}`, + ); + expect(before.crossings).toBe(1); + expect(after.crossings).toBe(0); + expect(after.overlap).toBeLessThan(0.1); // didn't introduce overlap + }); + + it("leaves a tiny graph (n < 3) untouched", () => { + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, + { x: 50, y: 0, radius: 10 }, + ]; + optimizeTopLevel(nodes, [[0, 1]], 1); + expect(nodes[0]!.x).toBe(0); + expect(nodes[1]!.x).toBe(50); + }); +}); + +describe("optimizeTopLevel anchored refine (incremental stability)", () => { + it("keeps an already-good layout in place instead of re-deriving it", () => { + const ring = 120; + const radius = 12; + const nodes = polygon(6, ring, radius); + const edges = ringEdges(6); + const anchors: (Anchor | null)[] = nodes.map((node) => ({ + x: node.x, + y: node.y, + })); + + optimizeTopLevel(nodes, edges, 4242, { anchors }); + + // Every existing bubble stays within a fraction of its own radius of where + // it was: the refine doesn't reshuffle a layout that's already crossing-free. + const displacements = relativeDisplacements(nodes, anchors); + for (const displacement of displacements) { + expect(displacement).toBeLessThan(radius); + } + expect(measureLayout(nodes, edges).crossings).toBe(0); + }); + + it("places a new bubble without disturbing the existing arrangement", () => { + const ring = 120; + const radius = 12; + const existing = polygon(6, ring, radius); + const anchors: (Anchor | null)[] = [ + ...existing.map((node) => ({ x: node.x, y: node.y })), + null, // the 7th bubble is new — placed freely + ]; + + // New bubble seeded near the centre, connected to one existing bubble. + const nodes: LayoutNode[] = [...existing, { x: 5, y: 5, radius }]; + const edges: [number, number][] = [...ringEdges(6), [6, 0]]; + + optimizeTopLevel(nodes, edges, 99, { anchors }); + + // The 6 existing bubbles barely move (mental map preserved)… + const displacements = relativeDisplacements(nodes, anchors); + for (let idx = 0; idx < 6; idx++) { + expect(displacements[idx]!).toBeLessThan(2 * radius); + } + // …while the new bubble leaves its poor central seed to find open space. + const newNode = nodes[6]!; + expect(Math.hypot(newNode.x - 5, newNode.y - 5)).toBeGreaterThan(radius); + for (const node of nodes) { + expect(Number.isFinite(node.x)).toBe(true); + expect(Number.isFinite(node.y)).toBe(true); + } + }); + + it("re-optimises far less aggressively than a cold search (the fix)", () => { + // The same poor seed (six bubbles strung along a line) optimised two ways. + // A cold global search re-derives a clean arrangement, moving bubbles a long + // way; the anchored refine keeps them near their previous spot. This is the + // erratic-vs-stable contrast the anchoring exists to fix. + const radius = 12; + const seed = (): LayoutNode[] => + Array.from({ length: 6 }, (_, idx) => ({ + x: (idx - 2.5) * 44, + y: idx % 2 === 0 ? 9 : -9, + radius, + })); + const edges = ringEdges(6); + const anchors: (Anchor | null)[] = seed().map((node) => ({ + x: node.x, + y: node.y, + })); + const totalMovement = (nodes: readonly LayoutNode[]): number => + relativeDisplacements(nodes, anchors).reduce( + (sum, dist) => sum + dist, + 0, + ); + + const cold = seed(); + optimizeTopLevel(cold, edges, 555); + + const refined = seed(); + optimizeTopLevel(refined, edges, 555, { anchors }); + + const coldMovement = totalMovement(cold); + const refinedMovement = totalMovement(refined); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] movement cold ${coldMovement.toFixed(1)} vs anchored ${refinedMovement.toFixed(1)}`, + ); + // The anchored refine keeps bubbles close to their previous positions… + expect(refinedMovement).toBeLessThan(coldMovement); + // …and the geometry it produces is no worse than the seed it started from. + expect(measureLayout(refined, edges).energy).toBeLessThanOrEqual( + measureLayout(seed(), edges).energy + 1e-6, + ); + }); + + it("lets a low-weight (off-screen) bubble move while a high-weight one stays", () => { + // Two connected bubbles seeded far apart, so stress pulls them together. + // One is pinned (weight 1, "on screen"), the other nearly free (weight 0.02, + // "off screen"); the free one should yield while the pinned one holds. The + // third bubble is an isolated, fully-anchored frame reference. + const radius = 10; + const nodes: LayoutNode[] = [ + { x: -40, y: 0, radius }, + { x: 40, y: 0, radius }, + { x: 0, y: 120, radius }, + ]; + const anchors: (Anchor | null)[] = [ + { x: -40, y: 0, weight: 1 }, + { x: 40, y: 0, weight: 0.02 }, + { x: 0, y: 120, weight: 1 }, + ]; + const edges: [number, number][] = [[0, 1]]; + + optimizeTopLevel(nodes, edges, 31, { anchors }); + + const pinnedMove = Math.hypot(nodes[0]!.x + 40, nodes[0]!.y); + const freeMove = Math.hypot(nodes[1]!.x - 40, nodes[1]!.y); + + // eslint-disable-next-line no-console + console.log( + `[toplevel] weighted move pinned ${pinnedMove.toFixed(1)} vs free ${freeMove.toFixed(1)}`, + ); + expect(freeMove).toBeGreaterThan(pinnedMove); + expect(pinnedMove).toBeLessThan(radius); + }); + + it("guarantees zero overlap on growth, where the search leaves a sliver", () => { + // A central bubble grew huge (radius 200) and is pinned, and so are its two + // neighbours (all weight 1, all on screen) at positions now buried inside + // it. The anchored search resolves MOST of this on its own, but — anchored + // to infeasible (overlapping) positions — it leaves a residual sliver it + // won't close. The final relaxation must turn that sliver into exactly zero. + const seedNodes = (): LayoutNode[] => [ + { x: 0, y: 0, radius: 200 }, + { x: 40, y: 0, radius: 12 }, + { x: -40, y: 0, radius: 12 }, + ]; + const anchors: (Anchor | null)[] = [ + { x: 0, y: 0, weight: 1 }, + { x: 40, y: 0, weight: 1 }, + { x: -40, y: 0, weight: 1 }, + ]; + const edges: [number, number][] = [ + [0, 1], + [0, 2], + ]; + + // The search alone (relaxation suppressed) leaves a measurable overlap. + const searchOnly = seedNodes(); + optimizeTopLevel(searchOnly, edges, 23, { + anchors, + skipOverlapRelaxation: true, + }); + const searchOnlyOverlap = measureLayout(searchOnly, edges).overlap; + expect(searchOnlyOverlap).toBeGreaterThan(0.01); + + // The full pass drives it to zero — the relaxation is what closes the gap. + const nodes = seedNodes(); + optimizeTopLevel(nodes, edges, 23, { anchors }); + const fullOverlap = measureLayout(nodes, edges).overlap; + expect(fullOverlap).toBeLessThan(1e-9); + expect(fullOverlap).toBeLessThan(searchOnlyOverlap); + }); + + it("relaxOverlaps separates overlaps and moves the heavier bubble less", () => { + // Two overlapping bubbles, B four times as heavy (pinned) as A. The push + // apart must (a) actually separate them and (b) move the heavier one less, + // i.e. distribute by weight rather than 50/50. No SA involved — this is the + // relaxation in isolation. + const meanRadius = 10; + const nodes: LayoutNode[] = [ + { x: 0, y: 0, radius: 10 }, // A: light + { x: 8, y: 0, radius: 10 }, // B: heavy, overlapping A + ]; + const anchors: (Anchor | null)[] = [ + { x: 0, y: 0, weight: 1 }, + { x: 8, y: 0, weight: 4 }, + ]; + + relaxOverlaps(nodes, anchors, meanRadius); + + // (a) No overlap left: centre distance >= radii + the objective's pad. + const pad = 0.3 * meanRadius; + const separation = Math.hypot(nodes[1]!.x - nodes[0]!.x, nodes[1]!.y); + expect(separation).toBeGreaterThanOrEqual(20 + pad - 1e-6); + // (b) The heavier bubble moved less than the lighter one. + const moveA = Math.abs(nodes[0]!.x - 0); + const moveB = Math.abs(nodes[1]!.x - 8); + expect(moveB).toBeLessThan(moveA); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts new file mode 100644 index 00000000000..0390b1e2f87 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts @@ -0,0 +1,645 @@ +/* eslint-disable no-param-reassign, id-length -- numeric optimiser: in-place position mutation and short math identifiers (x/y/dx) read best here. */ +/** + * Principled top-level layout optimiser. + * + * Why the top level is special. Inside a cluster, children are confined to the + * parent circle, similarly sized, and their external pull is handled by port + * anchors; WebCola's stress + non-overlap (plus the anchors) is the right tool + * there. The outside is harder: top-level clusters are unconfined, vary + * enormously in size (a 5 500-node bubble next to a 76-node one), and the edges + * between them have to get past those huge obstacles. Stress alone doesn't see + * crossings or detours, and the old pipeline optimised a proxy at each stage + * (WebCola centres -> untangle centres -> ports placed afterwards -> edges + * routed), so nothing ever minimised what you actually see. The top level is + * also the overview every deeper decision inherits, so it's worth solving + * directly. + * + * This optimiser minimises one objective over the small top-level layout, + * evaluated on the geometry that gets drawn: edges leave each bubble at the rim + * facing their neighbour (a port), and we score crossings + detours of those + * rim-to-rim segments, plus edge length (connected-near), non-overlap, and + * neighbour spread (so a cluster's connections fan out instead of bunching, the + * "mitosis" intuition, now an objective term rather than a separate seed). The N + * is tiny, so a simulated-annealing search with position swaps and restarts gets + * a near-optimal layout cheaply. + * + * Stability (mental-map preservation). A from-scratch global search is the right + * tool for the FIRST build, but re-running it on every ingest makes the top + * level jump around: the search is a near-optimal but discontinuous function of + * its input, so adding one bubble re-derives a completely different (if equally + * good) arrangement. So on an incremental update the caller passes the previous + * positions as {@link Anchor}s and the search becomes a LOCAL refine: an inertia + * term penalises moving an existing bubble away from its anchor, and the search + * takes small steps with few restarts and rare swaps. New bubbles (null anchor) + * are still placed freely, and the layout keeps self-healing crossings — it just + * does so by small, legible adjustments instead of wholesale reshuffles. + */ +import { mulberry32 } from "../random"; + +/** A node being laid out; `x`/`y` are mutated in place. */ +export interface LayoutNode { + x: number; + y: number; + readonly radius: number; +} + +/** A node's previous position, used to anchor it during an incremental refine. */ +export interface Anchor { + readonly x: number; + readonly y: number; + /** + * Per-node multiplier on the inertia weight, in [0, 1]: 1 pins the node to its + * previous position, 0 lets it move freely (defaults to 1). The caller sets it + * from how central the bubble is on screen, so what the user is looking at + * stays put while off-screen bubbles are free to reflow. + */ + readonly weight?: number; +} + +export interface OptimizeTopLevelOptions { + /** + * Previous positions, parallel to `nodes`. A non-null entry anchors that node + * (it existed in the prior layout and should keep its place); a null entry is + * a new node, placed freely. Supplying any anchor switches the search to a + * local, stability-preserving refine; omit entirely for a cold global build. + */ + readonly anchors?: readonly (Anchor | null)[]; + /** + * Skip the final non-overlap relaxation. Exists so tests can assert that the + * anchored search ALONE leaves a grown-bubble overlap unresolved (proving the + * relaxation is what clears it); production never sets it. + */ + readonly skipOverlapRelaxation?: boolean; +} + +/** Objective weights. Crossings, detours, and overlap dominate (legibility); */ +/** stress (normalised, scale-free) and spread are gentle shaping terms. */ +const CROSS_WEIGHT = 30; +const DETOUR_WEIGHT = 24; +const OVERLAP_WEIGHT = 40; +const STRESS_WEIGHT = 3; +const SPREAD_WEIGHT = 2; +/** + * Ideal edge length = the endpoints' combined radii + a gap (a fraction of the + * mean radius). Additive, not multiplicative: a multiplicative ideal makes any + * edge touching a huge bubble "want" to be hundreds of px long (the big radius + * dominates), so a connected small node gets flung far and the stress term still + * reads it as near-ideal, connected nodes travel large distances for no + * reason. An additive gap keeps the desired rim separation modest and uniform + * regardless of bubble size, so connected nodes sit close. Must stay >= the + * overlap pad ({@link OVERLAP_PAD_FRAC}) so stress and non-overlap don't fight. + */ +const IDEAL_GAP_FRAC = 0.5; +/** Non-overlap gap as a fraction of the mean radius. */ +const OVERLAP_PAD_FRAC = 0.3; + +const START_TEMP = 25; +const COOLING = 0.9975; +const STEPS = 1600; +const RESTARTS = 8; +/** Fraction of moves that are position swaps (the rest are jitters). */ +const SWAP_PROB = 0.25; + +/** + * Incremental-refine (anchored) search. When previous positions are supplied, + * the search stays LOCAL so existing bubbles keep their place: an inertia term + * adds {@link ANCHOR_WEIGHT}·(displacement / meanRadius)² per anchored node to + * the objective, and the search takes small steps ({@link ANCHORED_MOVE_SCALE_MUL} + * of the mean radius) with a single pass ({@link ANCHORED_RESTARTS}) and rare + * swaps ({@link ANCHORED_SWAP_PROB} — a swap relocates a node across the whole + * layout, the opposite of staying put). The weight is calibrated against + * {@link CROSS_WEIGHT}: at weight 12 a node won't travel ~2 mean-radii from its + * anchor unless doing so removes more than ~1.5 crossings, so the layout only + * reshuffles when it genuinely helps. Cold builds (no anchors) keep the global + * constants above. + */ +const ANCHOR_WEIGHT = 12; +const ANCHORED_START_TEMP = 6; +const ANCHORED_RESTARTS = 1; +const ANCHORED_SWAP_PROB = 0.05; +const ANCHORED_MOVE_SCALE_MUL = 1.2; + +/** + * Final non-overlap relaxation (anchored refine only). Inertia is quadratic and + * the overlap penalty is linear, so for a LARGE forced displacement — a bubble + * that suddenly grew (e.g. 70 → 2000 entities, radius ~5×) whose neighbours are + * all pinned near the viewport — the anchor wins and the grown bubble is left + * overlapping. This deterministic push-apart runs after the search and + * GUARANTEES a non-overlapping result, distributing the separation by anchor + * weight (as mass): a pinned central bubble barely moves while lighter / + * off-screen neighbours yield. A no-op when nothing overlaps, so it never + * disturbs a stable layout. + */ +const OVERLAP_RELAX_ITERS = 64; +/** Relaxation mass of an un-anchored (new) bubble: very light, so it yields + * freely rather than shoving an anchored neighbour. Below the viewport floor. */ +const FREE_NODE_MASS = 0.01; + +/** The rim point of `node` in the direction of `(tx,ty)`, where an edge to that + * neighbour attaches (its port, before any min-separation nudge). */ +function rim(node: LayoutNode, tx: number, ty: number): [number, number] { + const dx = tx - node.x; + const dy = ty - node.y; + const dist = Math.hypot(dx, dy) || 1; + return [ + node.x + (node.radius * dx) / dist, + node.y + (node.radius * dy) / dist, + ]; +} + +/** Standard segment-intersection test (proper crossings; shared endpoints handled by the caller). */ +function segmentsCross( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + dx: number, + dy: number, +): boolean { + const d1 = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); + const d2 = (bx - ax) * (dy - ay) - (by - ay) * (dx - ax); + const d3 = (dx - cx) * (ay - cy) - (dy - cy) * (ax - cx); + const d4 = (dx - cx) * (by - cy) - (dy - cy) * (bx - cx); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +/** How deep a segment intrudes into a circle (0 if it stays clear). */ +function circleIntrusion( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, + radius: number, +): number { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + let t = len2 > 0 ? ((cx - ax) * dx + (cy - ay) * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const px = ax + t * dx; + const py = ay + t * dy; + const dist = Math.hypot(cx - px, cy - py); + return dist < radius ? radius - dist : 0; +} + +interface Problem { + readonly edges: readonly (readonly [number, number])[]; + readonly adjacency: readonly (readonly number[])[]; + readonly ideals: readonly number[]; + readonly meanRadius: number; +} + +function buildProblem( + nodes: readonly LayoutNode[], + edges: readonly (readonly [number, number])[], +): Problem { + let meanRadius = 0; + for (const node of nodes) { + meanRadius += node.radius; + } + meanRadius = nodes.length > 0 ? meanRadius / nodes.length : 1; + + const adjacency: number[][] = nodes.map(() => []); + const ideals: number[] = []; + const gap = meanRadius * IDEAL_GAP_FRAC; + for (const [a, b] of edges) { + adjacency[a]!.push(b); + adjacency[b]!.push(a); + ideals.push(nodes[a]!.radius + nodes[b]!.radius + gap); + } + return { edges, adjacency, ideals, meanRadius }; +} + +interface LayoutTerms { + crossings: number; + detour: number; + overlap: number; + stress: number; + spread: number; +} + +/** Fill `terms` with the (unweighted) objective components for the positions. */ +function computeTerms( + nodes: readonly LayoutNode[], + problem: Problem, + terms: LayoutTerms, +): void { + const { edges, adjacency, ideals, meanRadius } = problem; + const edgeCount = edges.length; + + // Rim attach points for each edge end (the drawn segment endpoints). + const ax = new Float64Array(edgeCount); + const ay = new Float64Array(edgeCount); + const bx = new Float64Array(edgeCount); + const by = new Float64Array(edgeCount); + for (let e = 0; e < edgeCount; e++) { + const [a, b] = edges[e]!; + const na = nodes[a]!; + const nb = nodes[b]!; + const pa = rim(na, nb.x, nb.y); + const pb = rim(nb, na.x, na.y); + ax[e] = pa[0]; + ay[e] = pa[1]; + bx[e] = pb[0]; + by[e] = pb[1]; + } + + let crossings = 0; + for (let e = 0; e < edgeCount; e++) { + const [a1, b1] = edges[e]!; + for (let f = e + 1; f < edgeCount; f++) { + const [a2, b2] = edges[f]!; + if (a1 === a2 || a1 === b2 || b1 === a2 || b1 === b2) { + continue; // share a node, not a crossing + } + if ( + segmentsCross( + ax[e]!, + ay[e]!, + bx[e]!, + by[e]!, + ax[f]!, + ay[f]!, + bx[f]!, + by[f]!, + ) + ) { + crossings += 1; + } + } + } + + let detour = 0; + for (let e = 0; e < edgeCount; e++) { + const [a, b] = edges[e]!; + for (let w = 0; w < nodes.length; w++) { + if (w === a || w === b) { + continue; + } + const node = nodes[w]!; + const intr = circleIntrusion( + ax[e]!, + ay[e]!, + bx[e]!, + by[e]!, + node.x, + node.y, + node.radius, + ); + if (intr > 0) { + detour += intr / node.radius; // fraction of the bubble pierced + } + } + } + + let stress = 0; + for (let e = 0; e < edgeCount; e++) { + const [a, b] = edges[e]!; + const dist = Math.hypot( + nodes[a]!.x - nodes[b]!.x, + nodes[a]!.y - nodes[b]!.y, + ); + const ratio = dist / ideals[e]! - 1; + stress += ratio * ratio; + } + + let overlap = 0; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const ni = nodes[i]!; + const nj = nodes[j]!; + const dist = Math.hypot(ni.x - nj.x, ni.y - nj.y); + const minDist = ni.radius + nj.radius + OVERLAP_PAD_FRAC * meanRadius; + if (dist < minDist) { + overlap += (minDist - dist) / meanRadius; + } + } + } + + let spread = 0; + for (let u = 0; u < nodes.length; u++) { + const neighbours = adjacency[u]!; + if (neighbours.length < 2) { + continue; + } + const node = nodes[u]!; + const angles = neighbours + .map((v) => Math.atan2(nodes[v]!.y - node.y, nodes[v]!.x - node.x)) + .sort((lhs, rhs) => lhs - rhs); + const idealGap = (2 * Math.PI) / angles.length; + for (let k = 0; k < angles.length; k++) { + const next = angles[(k + 1) % angles.length]!; + let gap = next - angles[k]!; + if (gap <= 0) { + gap += 2 * Math.PI; + } + if (gap < idealGap) { + spread += (idealGap - gap) / idealGap; + } + } + } + + terms.crossings = crossings; + terms.detour = detour; + terms.overlap = overlap; + terms.stress = stress; + terms.spread = spread; +} + +function weightedTotal(terms: LayoutTerms): number { + return ( + CROSS_WEIGHT * terms.crossings + + DETOUR_WEIGHT * terms.detour + + OVERLAP_WEIGHT * terms.overlap + + STRESS_WEIGHT * terms.stress + + SPREAD_WEIGHT * terms.spread + ); +} + +/** The drawn-geometry objective, broken down, for tests and diagnostics. */ +export interface LayoutMeasure extends LayoutTerms { + readonly energy: number; +} + +export function measureLayout( + nodes: readonly LayoutNode[], + edges: readonly (readonly [number, number])[], +): LayoutMeasure { + const problem = buildProblem(nodes, edges); + const terms: LayoutTerms = { + crossings: 0, + detour: 0, + overlap: 0, + stress: 0, + spread: 0, + }; + computeTerms(nodes, problem, terms); + return { ...terms, energy: weightedTotal(terms) }; +} + +/** + * Push overlapping nodes apart in place until none overlap (or the iteration + * budget runs out), distributing each pair's separation by anchor weight as a + * mass: a node moves proportionally to the OTHER's mass, so a heavy (pinned, + * high-weight) bubble barely moves and a light (off-screen or new) one yields. + * Uses the same minimum separation as the overlap objective term, so the result + * scores zero overlap. See {@link OVERLAP_RELAX_ITERS}. + */ +export function relaxOverlaps( + nodes: LayoutNode[], + anchors: readonly (Anchor | null)[], + meanRadius: number, +): void { + const pad = OVERLAP_PAD_FRAC * meanRadius; + const count = nodes.length; + for (let iter = 0; iter < OVERLAP_RELAX_ITERS; iter++) { + let moved = false; + for (let i = 0; i < count; i++) { + const ni = nodes[i]!; + for (let j = i + 1; j < count; j++) { + const nj = nodes[j]!; + let dx = nj.x - ni.x; + let dy = nj.y - ni.y; + let dist = Math.hypot(dx, dy); + const minDist = ni.radius + nj.radius + pad; + if (dist >= minDist) { + continue; + } + if (dist < 1e-6) { + // Coincident: pick a deterministic direction from the indices. + const angle = (i * 1.3 + j * 0.7) % (2 * Math.PI); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = 1; + } + const penetration = minDist - dist; + const massI = anchors[i]?.weight ?? FREE_NODE_MASS; + const massJ = anchors[j]?.weight ?? FREE_NODE_MASS; + const total = massI + massJ; + // Each node moves proportional to the OTHER's mass (heavy moves less). + const shareI = total > 0 ? massJ / total : 0.5; + const shareJ = total > 0 ? massI / total : 0.5; + const ux = dx / dist; + const uy = dy / dist; + ni.x -= ux * penetration * shareI; + ni.y -= uy * penetration * shareI; + nj.x += ux * penetration * shareJ; + nj.y += uy * penetration * shareJ; + moved = true; + } + } + if (!moved) { + break; + } + } +} + +/** + * Optimise the top-level node positions in place, minimising the drawn-geometry + * objective. `nodes` should already hold a reasonable seed (e.g. WebCola's + * stress result). `seed` makes the search deterministic. Pass + * {@link OptimizeTopLevelOptions.anchors} to switch from a cold global search to + * an incremental local refine that keeps anchored nodes near their previous + * positions (see the stability note in the module header). + */ +export function optimizeTopLevel( + nodes: LayoutNode[], + edges: readonly (readonly [number, number])[], + seed: number, + options?: OptimizeTopLevelOptions, +): void { + const count = nodes.length; + if (count < 3 || edges.length === 0) { + return; + } + const problem = buildProblem(nodes, edges); + const rng = mulberry32(seed); + const terms: LayoutTerms = { + crossings: 0, + detour: 0, + overlap: 0, + stress: 0, + spread: 0, + }; + + const rawAnchors = options?.anchors; + const anchored = + rawAnchors !== undefined && rawAnchors.some((anchor) => anchor !== null); + + // Align the anchor cloud to the seed, so inertia penalises RELATIVE + // rearrangement only. The layout is re-centred on its centroid (which shifts + // when a node is added/removed), so the raw anchors and the seed differ by a + // uniform translation we must not fight. The alignment is WEIGHTED, pinning + // the frame to the heavily-anchored (central) nodes, so they keep their + // on-screen position while low-weight nodes drift. + const anchors: (Anchor | null)[] = new Array(count).fill(null); + if (anchored) { + let anchorMeanX = 0; + let anchorMeanY = 0; + let seedMeanX = 0; + let seedMeanY = 0; + let totalWeight = 0; + for (let i = 0; i < count; i++) { + const anchor = rawAnchors[i]; + if (!anchor) { + continue; + } + const weight = anchor.weight ?? 1; + anchorMeanX += anchor.x * weight; + anchorMeanY += anchor.y * weight; + seedMeanX += nodes[i]!.x * weight; + seedMeanY += nodes[i]!.y * weight; + totalWeight += weight; + } + const offsetX = + totalWeight > 0 ? (seedMeanX - anchorMeanX) / totalWeight : 0; + const offsetY = + totalWeight > 0 ? (seedMeanY - anchorMeanY) / totalWeight : 0; + for (let i = 0; i < count; i++) { + const anchor = rawAnchors[i]; + if (anchor) { + anchors[i] = { + x: anchor.x + offsetX, + y: anchor.y + offsetY, + weight: anchor.weight, + }; + } + } + } + + const anchorScale = Math.max(problem.meanRadius, 1); + const anchorEnergy = (): number => { + if (!anchored) { + return 0; + } + let sum = 0; + for (let i = 0; i < count; i++) { + const anchor = anchors[i]; + if (!anchor) { + continue; + } + const dx = nodes[i]!.x - anchor.x; + const dy = nodes[i]!.y - anchor.y; + const weight = anchor.weight ?? 1; + sum += (weight * (dx * dx + dy * dy)) / (anchorScale * anchorScale); + } + return ANCHOR_WEIGHT * sum; + }; + + const evalEnergy = (): number => { + computeTerms(nodes, problem, terms); + return weightedTotal(terms) + anchorEnergy(); + }; + + // Move scale ~ the layout's extent. + let extent = 0; + for (const node of nodes) { + extent = Math.max(extent, Math.hypot(node.x, node.y) + node.radius); + } + extent = Math.max(extent, problem.meanRadius); + + // Anchored: a local refine (small steps, one pass, rare swaps). Cold: the + // full global search. `jitterBasis` also scales the restart kick, so an + // anchored pass never throws a node across the layout. + const restarts = anchored ? ANCHORED_RESTARTS : RESTARTS; + const startTemp = anchored ? ANCHORED_START_TEMP : START_TEMP; + const swapProb = anchored ? ANCHORED_SWAP_PROB : SWAP_PROB; + const jitterBasis = anchored ? anchorScale * ANCHORED_MOVE_SCALE_MUL : extent; + + const bestX = nodes.map((node) => node.x); + const bestY = nodes.map((node) => node.y); + let bestEnergy = evalEnergy(); + + for (let restart = 0; restart < restarts; restart++) { + // Restart 0 keeps the seed; later restarts jitter it to escape minima. + if (restart > 0) { + for (let i = 0; i < count; i++) { + nodes[i]!.x = bestX[i]! + (rng() - 0.5) * jitterBasis; + nodes[i]!.y = bestY[i]! + (rng() - 0.5) * jitterBasis; + } + } else { + for (let i = 0; i < count; i++) { + nodes[i]!.x = bestX[i]!; + nodes[i]!.y = bestY[i]!; + } + } + + let current = evalEnergy(); + let temp = startTemp; + + for (let step = 0; step < STEPS; step++) { + if (rng() < swapProb) { + // Swap two nodes' positions: the move that un-crosses a layout. + const i = Math.floor(rng() * count); + let j = Math.floor(rng() * count); + if (j === i) { + j = (j + 1) % count; + } + const tx = nodes[i]!.x; + const ty = nodes[i]!.y; + nodes[i]!.x = nodes[j]!.x; + nodes[i]!.y = nodes[j]!.y; + nodes[j]!.x = tx; + nodes[j]!.y = ty; + const candidate = evalEnergy(); + if ( + candidate <= current || + rng() < Math.exp((current - candidate) / temp) + ) { + current = candidate; + } else { + nodes[j]!.x = nodes[i]!.x; + nodes[j]!.y = nodes[i]!.y; + nodes[i]!.x = tx; + nodes[i]!.y = ty; + } + } else { + // Jitter one node (annealed step size). + const i = Math.floor(rng() * count); + const ox = nodes[i]!.x; + const oy = nodes[i]!.y; + const scale = (jitterBasis * temp) / startTemp; + nodes[i]!.x = ox + (rng() - 0.5) * scale; + nodes[i]!.y = oy + (rng() - 0.5) * scale; + const candidate = evalEnergy(); + if ( + candidate <= current || + rng() < Math.exp((current - candidate) / temp) + ) { + current = candidate; + } else { + nodes[i]!.x = ox; + nodes[i]!.y = oy; + } + } + temp *= COOLING; + } + + if (current < bestEnergy) { + bestEnergy = current; + for (let i = 0; i < count; i++) { + bestX[i] = nodes[i]!.x; + bestY[i] = nodes[i]!.y; + } + } + } + + for (let i = 0; i < count; i++) { + nodes[i]!.x = bestX[i]!; + nodes[i]!.y = bestY[i]!; + } + + // Anchoring pins bubbles to their previous positions, which become infeasible + // when one grows. The search clears most of the resulting overlap, but anchored + // to overlapping positions it can leave a residual sliver it won't close (the + // quadratic inertia holds bubbles short of fully separating). Guarantee a + // non-overlapping result, moving lighter/off-screen bubbles before pinned + // central ones. (Cold builds resolve overlap during the global search.) + if (anchored && options?.skipOverlapRelaxation !== true) { + relaxOverlaps(nodes, anchors, problem.meanRadius); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts new file mode 100644 index 00000000000..fbf98858627 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts @@ -0,0 +1,520 @@ +/* eslint-disable id-length, no-param-reassign */ +/** + * D1: small-N layout solver (crossing + readability minimisation). + * + * The objective is self-contained: it does not reference the force layout. We + * minimise what actually makes a small node-link layout bad: + * + * energy = crossings + edge-through-node + node overlap + * + linked-pairs-too-far (the link "meaning": connected -> near) + * + compactness (stay near the centre of the allotted disc) + * + * and search the full configuration space with simulated annealing from several + * restarts, keeping the lowest-energy result. A warm-start init (e.g. the force + * layout or a SMACOF embedding) can be passed in via the node positions, but it + * has no privileged hold: there is no anchor term tethering us to it; it just + * seeds restart 0 and competes on equal terms with jittered/random restarts. + * The committed layout is the optimum of the real objective, not a polish of + * whatever local minimum the seed fell into. + * + * Two correctness notes: + * - Temperature is on the energy scale (a crossing costs `CROSS_WEIGHT`), so + * `exp(-Δ/T)` actually accepts uphill moves early and this is real annealing, + * not greedy descent. + * - Crossing minimisation is NP-hard, so "directly" means full-space search + * with the true objective + restarts (near-optimal for the dozens of nodes we + * have at the cluster level), not a provable global optimum. + * + * A seeded PRNG makes every run deterministic. + * + * Per-move cost is O(degree*E + N): only the moved node's incident edges and its + * own overlaps change, so we evaluate the delta incrementally. The full-layout + * `totalEnergy` (O(E² + E*N + N²)) is computed only once per restart, to pick + * the winner. + */ +import { mulberry32 } from "../random"; + +/** Mutated in place. Positions are in the layout's local frame (origin-centred). */ +export interface UntangleNode { + x: number; + y: number; + readonly radius: number; +} + +export interface UntangleOptions { + /** Index pairs into `nodes`: the edges whose crossings we minimise. */ + readonly edges: readonly (readonly [number, number])[]; + /** Confine nodes within this radius of the origin (Infinity = free). */ + readonly confinementRadius: number; + /** Deterministic seed so re-runs reproduce the layout. */ + readonly seed: number; + /** Anneal iterations per restart. Defaults to scale with N. */ + readonly iterations?: number; + /** Independent annealing runs; the lowest-energy one wins. Defaults to 6. */ + readonly restarts?: number; +} + +// Crossings (and edges through bubbles) dominate the soft link-length / +// compactness terms, so the 2-opt swaps and annealing prioritise removing them +// over a slightly longer edge; crossing reduction is the goal here. +const CROSS_WEIGHT = 20; +const THROUGH_WEIGHT = 28; +const OVERLAP_WEIGHT = 6; +/** Pull linked clusters together: per unit an edge is longer than ideal. */ +const LINK_WEIGHT = 0.02; +/** Ideal edge length as a multiple of the endpoints' combined radii. */ +const LINK_IDEAL_MUL = 1.5; +/** Keep the layout compact: per unit a node sits from the origin. */ +const COMPACT_WEIGHT = 0.012; +/** Keep an edge this far (x node radius) clear of a non-incident node. */ +const THROUGH_CLEARANCE = 1.15; +/** + * Initial annealing temperature, on the energy scale (~ a couple of crossings), + * so uphill moves are genuinely accepted early. Cools to ~0 over the run. + */ +const START_TEMP = 25; +const DEFAULT_RESTARTS = 6; +/** Above this node count, skip 2-opt swaps (the O(N²*|energy|) pass gets slow). */ +const TWO_OPT_MAX_NODES = 24; +/** Cap on 2-opt passes; it converges in a few full pairwise sweeps. */ +const TWO_OPT_PASSES = 4; + +/** Orientation sign of (a, b, c). */ +function orient( + ax: number, + ay: number, + bx: number, + by: number, + cx: number, + cy: number, +): number { + return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax); +} + +/** Do open segments (p1,p2) and (p3,p4) properly cross? Shared endpoints don't. */ +function segmentsCross( + p1x: number, + p1y: number, + p2x: number, + p2y: number, + p3x: number, + p3y: number, + p4x: number, + p4y: number, +): boolean { + const d1 = orient(p3x, p3y, p4x, p4y, p1x, p1y); + const d2 = orient(p3x, p3y, p4x, p4y, p2x, p2y); + const d3 = orient(p1x, p1y, p2x, p2y, p3x, p3y); + const d4 = orient(p1x, p1y, p2x, p2y, p4x, p4y); + return d1 * d2 < 0 && d3 * d4 < 0; +} + +/** Squared distance from point (px,py) to segment (ax,ay)-(bx,by). */ +function pointSegDist2( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, +): number { + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + const t = + len2 < 1e-9 + ? 0 + : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2)); + const qx = ax + t * dx; + const qy = ay + t * dy; + return (px - qx) ** 2 + (py - qy) ** 2; +} + +/** Ideal centre-to-centre distance for a linked pair. */ +function idealLinkDist(a: UntangleNode, b: UntangleNode): number { + return (a.radius + b.radius) * LINK_IDEAL_MUL; +} + +/** + * Energy of every term that involves node `i`. Moving only `i` changes exactly + * these terms, so `Δtotal = nodeEnergy(after) - nodeEnergy(before)`. + */ +function nodeEnergy( + i: number, + nodes: readonly UntangleNode[], + edges: readonly (readonly [number, number])[], + incident: readonly number[], +): number { + const node = nodes[i]!; + let energy = 0; + + // Crossings of i's incident edges against every other edge. + for (const ie of incident) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let je = 0; je < edges.length; je++) { + if (je === ie) { + continue; + } + const [c, d] = edges[je]!; + // Skip edges sharing an endpoint (they meet, not cross). + if (c === a || c === b || d === a || d === b) { + continue; + } + if ( + segmentsCross( + ax, + ay, + bx, + by, + nodes[c]!.x, + nodes[c]!.y, + nodes[d]!.x, + nodes[d]!.y, + ) + ) { + energy += CROSS_WEIGHT; + } + } + } + + // i's incident edges passing through any other node. + for (const ie of incident) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let k = 0; k < nodes.length; k++) { + if (k === a || k === b) { + continue; + } + const clear = nodes[k]!.radius * THROUGH_CLEARANCE; + if ( + pointSegDist2(nodes[k]!.x, nodes[k]!.y, ax, ay, bx, by) < + clear * clear + ) { + energy += THROUGH_WEIGHT; + } + } + } + + // Any edge (not incident to i) passing through node i. + const clearI = node.radius * THROUGH_CLEARANCE; + for (let je = 0; je < edges.length; je++) { + const [c, d] = edges[je]!; + if (c === i || d === i) { + continue; + } + if ( + pointSegDist2( + node.x, + node.y, + nodes[c]!.x, + nodes[c]!.y, + nodes[d]!.x, + nodes[d]!.y, + ) < + clearI * clearI + ) { + energy += THROUGH_WEIGHT; + } + } + + // i overlapping other nodes. + for (let k = 0; k < nodes.length; k++) { + if (k === i) { + continue; + } + const minDist = node.radius + nodes[k]!.radius; + const dist = Math.hypot(node.x - nodes[k]!.x, node.y - nodes[k]!.y); + if (dist < minDist) { + energy += OVERLAP_WEIGHT * (minDist - dist); + } + } + + // Linked-pairs-too-far: pull i toward the clusters it links to. + for (const ie of incident) { + const [a, b] = edges[ie]!; + const other = nodes[a === i ? b : a]!; + const len = Math.hypot(node.x - other.x, node.y - other.y); + const ideal = idealLinkDist(node, other); + if (len > ideal) { + energy += LINK_WEIGHT * (len - ideal); + } + } + + // Compactness: a fixed geometric pull toward the disc centre (not an anchor to + // any prior layout, it references the origin, not the seed positions). + energy += COMPACT_WEIGHT * Math.hypot(node.x, node.y); + + return energy; +} + +/** Absolute objective for the whole layout, used to pick the best restart. */ +function totalEnergy( + nodes: readonly UntangleNode[], + edges: readonly (readonly [number, number])[], +): number { + let energy = 0; + + // Crossings: each unordered edge pair once. + for (let ie = 0; ie < edges.length; ie++) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let je = ie + 1; je < edges.length; je++) { + const [c, d] = edges[je]!; + if (c === a || c === b || d === a || d === b) { + continue; + } + if ( + segmentsCross( + ax, + ay, + bx, + by, + nodes[c]!.x, + nodes[c]!.y, + nodes[d]!.x, + nodes[d]!.y, + ) + ) { + energy += CROSS_WEIGHT; + } + } + } + + // Edge-through-node: each edge against each non-endpoint node. + for (let ie = 0; ie < edges.length; ie++) { + const [a, b] = edges[ie]!; + const ax = nodes[a]!.x; + const ay = nodes[a]!.y; + const bx = nodes[b]!.x; + const by = nodes[b]!.y; + for (let k = 0; k < nodes.length; k++) { + if (k === a || k === b) { + continue; + } + const clear = nodes[k]!.radius * THROUGH_CLEARANCE; + if ( + pointSegDist2(nodes[k]!.x, nodes[k]!.y, ax, ay, bx, by) < + clear * clear + ) { + energy += THROUGH_WEIGHT; + } + } + } + + // Overlap + compactness over each node; links over each edge once. + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]!; + for (let k = i + 1; k < nodes.length; k++) { + const minDist = node.radius + nodes[k]!.radius; + const dist = Math.hypot(node.x - nodes[k]!.x, node.y - nodes[k]!.y); + if (dist < minDist) { + energy += OVERLAP_WEIGHT * (minDist - dist); + } + } + energy += COMPACT_WEIGHT * Math.hypot(node.x, node.y); + } + for (let ie = 0; ie < edges.length; ie++) { + const [a, b] = edges[ie]!; + const len = Math.hypot( + nodes[a]!.x - nodes[b]!.x, + nodes[a]!.y - nodes[b]!.y, + ); + const ideal = idealLinkDist(nodes[a]!, nodes[b]!); + if (len > ideal) { + energy += LINK_WEIGHT * (len - ideal); + } + } + + return energy; +} + +/** Clamp a node inside the confinement circle (no-op if radius is Infinity). */ +function confine(node: UntangleNode, confinementRadius: number): void { + if (!Number.isFinite(confinementRadius)) { + return; + } + const limit = Math.max(0, confinementRadius - node.radius); + const dist = Math.hypot(node.x, node.y); + if (dist > limit && dist > 0) { + node.x = (node.x / dist) * limit; + node.y = (node.y / dist) * limit; + } +} + +/** One simulated-annealing descent over the current node positions, in place. */ +function annealOnce( + nodes: UntangleNode[], + edges: readonly (readonly [number, number])[], + incident: readonly (readonly number[])[], + confinementRadius: number, + rng: () => number, + iterations: number, +): void { + const count = nodes.length; + + let extent = 0; + for (const node of nodes) { + extent = Math.max(extent, Math.hypot(node.x, node.y) + node.radius); + } + const startScale = Math.max(1, extent * 0.4); + + for (let iter = 0; iter < iterations; iter++) { + const progress = iter / iterations; + const temperature = START_TEMP * (1 - progress) ** 2; + const scale = startScale * (1 - progress); + const i = Math.floor(rng() * count); + const node = nodes[i]!; + + const before = nodeEnergy(i, nodes, edges, incident[i]!); + const oldX = node.x; + const oldY = node.y; + + const angle = rng() * 2 * Math.PI; + const step = scale * rng(); + node.x += Math.cos(angle) * step; + node.y += Math.sin(angle) * step; + confine(node, confinementRadius); + + const after = nodeEnergy(i, nodes, edges, incident[i]!); + const delta = after - before; + + // Accept improvements always; uphill moves with annealing probability. + if (delta > 0 && rng() >= Math.exp(-delta / Math.max(1e-3, temperature))) { + node.x = oldX; + node.y = oldY; + } + } +} + +/** + * 2-opt position swaps: greedily exchange pairs of node positions, keeping any + * swap that lowers total energy, until none does (or the pass cap is hit). A + * single swap can un-cross many edges at once (the move single-node annealing + * nudges can not reach), so this is what actually minimises crossings for the + * small node counts at the cluster level. `totalEnergy` includes overlap, so a + * swap that would collide is rejected. O(passes * N² * |totalEnergy|), hence + * gated to small N by the caller. + */ +function twoOptSwaps( + nodes: UntangleNode[], + edges: readonly (readonly [number, number])[], +): void { + const count = nodes.length; + let base = totalEnergy(nodes, edges); + for (let pass = 0; pass < TWO_OPT_PASSES; pass++) { + let improved = false; + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + const ix = nodes[i]!.x; + const iy = nodes[i]!.y; + const jx = nodes[j]!.x; + const jy = nodes[j]!.y; + nodes[i]!.x = jx; + nodes[i]!.y = jy; + nodes[j]!.x = ix; + nodes[j]!.y = iy; + const energy = totalEnergy(nodes, edges); + if (energy < base - 1e-6) { + base = energy; + improved = true; + } else { + nodes[i]!.x = ix; + nodes[i]!.y = iy; + nodes[j]!.x = jx; + nodes[j]!.y = jy; + } + } + } + if (!improved) { + break; + } + } +} + +export function untangleLayout( + nodes: UntangleNode[], + options: UntangleOptions, +): void { + const { edges, confinementRadius, seed } = options; + const count = nodes.length; + if (count < 3 || edges.length === 0) { + return; + } + + // Per-node incident-edge index lists. + const incident: number[][] = Array.from({ length: count }, () => []); + for (let e = 0; e < edges.length; e++) { + const [a, b] = edges[e]!; + incident[a]!.push(e); + incident[b]!.push(e); + } + + const restarts = Math.max(1, options.restarts ?? DEFAULT_RESTARTS); + const iterations = options.iterations ?? Math.min(4000, count * 120); + const rng = mulberry32(seed); + + // The warm-start init the caller positioned `nodes` at (force / SMACOF). + const init = nodes.map((node) => ({ x: node.x, y: node.y })); + let extent = 0; + for (const node of init) { + extent = Math.max(extent, Math.hypot(node.x, node.y)); + } + const jitterScale = Math.max(1, extent * 0.6); + const randomRadius = Number.isFinite(confinementRadius) + ? confinementRadius + : Math.max(1, extent); + + let best = init.map((p) => ({ ...p })); + let bestEnergy = Infinity; + + for (let r = 0; r < restarts; r++) { + // Restart 0 starts from the given seed; later restarts perturb it (and the + // last starts fully random) so the search is not captured by the seed. + for (let i = 0; i < count; i++) { + const node = nodes[i]!; + if (r === 0) { + node.x = init[i]!.x; + node.y = init[i]!.y; + } else if (r === restarts - 1 && restarts > 2) { + const angle = rng() * 2 * Math.PI; + const rad = Math.sqrt(rng()) * randomRadius; + node.x = Math.cos(angle) * rad; + node.y = Math.sin(angle) * rad; + } else { + node.x = init[i]!.x + (rng() * 2 - 1) * jitterScale; + node.y = init[i]!.y + (rng() * 2 - 1) * jitterScale; + } + confine(node, confinementRadius); + } + + annealOnce(nodes, edges, incident, confinementRadius, rng, iterations); + + const energy = totalEnergy(nodes, edges); + if (energy < bestEnergy) { + bestEnergy = energy; + best = nodes.map((node) => ({ x: node.x, y: node.y })); + } + } + + for (let i = 0; i < count; i++) { + nodes[i]!.x = best[i]!.x; + nodes[i]!.y = best[i]!.y; + } + + // Polish the best result with 2-opt position swaps. For the small N at the + // cluster level this removes the crossings the single-node annealing only + // nudges at (a swap un-crosses what a nudge can't). + if (count <= TWO_OPT_MAX_NODES) { + twoOptSwaps(nodes, edges); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts new file mode 100644 index 00000000000..ff8f4acaa80 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts @@ -0,0 +1,316 @@ +import type { VizConfig } from "../config"; +import type { PositionsFrame, StructureFrame } from "../frames"; +import type { ClusterId, EntityIdx, VizMode } from "../ids"; +/** + * Message types for the worker <-> main thread boundary. + * + * Each message variant is its own interface so it's self-documenting, + * individually importable, and easy to match on. + * + * The worker owns all heavy state. The main thread receives only: + * - {@link StructureFrameMessage}: identities + topology, on cut change. + * - {@link PositionsFrameMessage}: bounded cluster positions + edge geometry, + * per tick while settling. + * - {@link LayoutCreatedMessage} / {@link LayoutDestroyedMessage}: the + * `SharedArrayBuffer` lifecycle for an open leaf's entity positions. + */ +import type { + EntityId, + LinkData, + PropertyObject, + VersionedUrl, +} from "@blockprotocol/type-system"; + +export interface TypeSchemaEntry { + readonly url: VersionedUrl; + readonly title: string; + /** + * For a link type, its inverse (target -> source) title, e.g. "Member Of" for "Has Member". + * Lets the worker label a reverse lane with the inverse title (a lane is per direction). + */ + readonly inverseTitle?: string; + readonly icon?: string; + readonly allOfRefs: readonly VersionedUrl[]; +} + +/** + * A property type's display title keyed by its base URL. Shipped alongside + * {@link TypeSchemaEntry} so the worker can render a property-based cluster label + * ("Destination = ...") with the human title rather than a raw base URL. The worker + * holds property VALUES (on {@link IngestEntity}); these supply the names for them. + */ +export interface PropertySchemaEntry { + readonly baseUrl: string; + readonly title: string; +} + +export interface IngestEntity { + readonly entityId: EntityId; + readonly entityTypeIds: readonly VersionedUrl[]; + readonly label?: string; + readonly isLink: boolean; + /** + * Whether this entity is a query ROOT. A non-root node is a FRONTIER node (a fetched link + * endpoint, rendered greyed-out until expanded). Carried on the ingest itself -- co-located with + * the entity -- so it is applied the moment the entity is interned and the first render already + * has the right colour, with no separate roots message. An expand re-sends a frontier node with + * this set to flip it. Always false for links. + */ + readonly isRoot: boolean; + readonly linkData?: LinkData; + /** + * The entity's property values, shipped for NODE entities so the worker can name an + * embedding cluster from the distinctive (property = value) signature its members + * share (see {@link PropertySchemaEntry}). The worker extracts the scalar features it + * needs itself; links carry none (they are never embedding-clustered). + */ + readonly properties?: PropertyObject; +} + +export interface InitMessage { + readonly type: "INIT"; + readonly config: VizConfig; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; +} + +export interface RegisterTypesMessage { + readonly type: "REGISTER_TYPES"; + readonly typeSchemas: readonly TypeSchemaEntry[]; + readonly propertySchemas: readonly PropertySchemaEntry[]; +} + +export interface IngestBatchMessage { + readonly type: "INGEST_BATCH"; + readonly batchId: string; + readonly entities: readonly IngestEntity[]; +} + +export interface ViewportChangedMessage { + readonly type: "VIEWPORT_CHANGED"; + readonly frameId: string; + readonly zoom: number; + readonly center: readonly [number, number]; + readonly width: number; + readonly height: number; +} + +export interface EmbeddingClusteringResultMessage { + readonly type: "EMBEDDING_CLUSTERING_RESULT"; + readonly clusterId: ClusterId; + readonly clusters: readonly { + readonly clusterId: number; + readonly entityIds: readonly string[]; + }[]; +} + +/** + * The currently-visible representative of one of a selected node's neighbors: the entity + * itself when it is individually rendered (the flat tier, or an open hierarchical leaf), else + * the visible cluster bubble it is collapsed into. Drives cross-cluster ego-highlight. + */ +export type EgoTarget = + | { readonly kind: "entity"; readonly entityIdx: EntityIdx } + | { readonly kind: "cluster"; readonly clusterId: ClusterId }; + +/** + * Ask for a selected node's ego: its neighbors' visible representatives. Async; the reply is + * an {@link EgoResultMessage} correlated by {@link requestId}. + */ +export interface QueryEgoMessage { + readonly type: "QUERY_EGO"; + readonly requestId: number; + /** EntityIdx (join key) of the selected node. */ + readonly entityIdx: EntityIdx; +} + +/** + * Pin a hierarchical leaf cluster open (its ancestors too) regardless of zoom, or null to + * clear. Set on selection; the worker forces it into the cut until cleared (birds-eye view). + */ +export interface SetPinnedMessage { + readonly type: "SET_PINNED"; + readonly clusterId: ClusterId | null; +} + +/** + * Set the highlighted entities (a selection's ego now, a path later): the worker keeps these + * at full colour and dims everyone else. Empty restores full colour. Generic -- the worker + * just dims the complement; the main thread decides what the set is. + */ +export interface SetHighlightMessage { + readonly type: "SET_HIGHLIGHT"; + readonly entityIdxs: readonly EntityIdx[]; +} + +/** + * Ask which link entities a clicked highway lane aggregates. Async; the reply is + * a {@link HighwayLinksResultMessage} correlated by {@link requestId}. The + * {@link laneId} is the lane's index in the worker's visual-edge list, carried + * to the main thread as the bezier segment `id` (see `RenderBezierBuffers.ids`). + */ +export interface QueryHighwayLinksMessage { + readonly type: "QUERY_HIGHWAY_LINKS"; + readonly requestId: number; + readonly laneId: number; +} + +export type MainToWorkerMessage = + | InitMessage + | RegisterTypesMessage + | IngestBatchMessage + | ViewportChangedMessage + | EmbeddingClusteringResultMessage + | QueryEgoMessage + | SetPinnedMessage + | SetHighlightMessage + | QueryHighwayLinksMessage; + +export interface ReadyMessage { + readonly type: "READY"; +} + +/** + * Topology changed (cut/ingest). Carries identities, styles, and edge + * topology. Sent rarely; held in a ref on the main thread with a version bump. + */ +export interface StructureFrameMessage { + readonly type: "STRUCTURE_FRAME"; + readonly frame: StructureFrame; +} + +/** + * Positions changed (force layout tick). Carries bounded cluster positions and + * freshly-computed edge geometry. Valid only against the latest + * {@link StructureFrameMessage}. + */ +export interface PositionsFrameMessage { + readonly type: "POSITIONS_FRAME"; + readonly frame: PositionsFrame; +} + +export interface ModeChangedMessage { + readonly type: "MODE_CHANGED"; + readonly oldMode: VizMode; + readonly newMode: VizMode; +} + +export interface EmbeddingClusteringNeededMessage { + readonly type: "EMBEDDING_CLUSTERING_NEEDED"; + readonly clusterId: ClusterId; + readonly entityIds: readonly string[]; + readonly clusterCount: number; +} + +/** + * An open leaf's entity positions are now backed by this buffer. Positions are + * LOCAL to the leaf center (`[x0, y0, x1, y1, ...]` after a leading int32 + * version counter); the main thread adds the leaf's world position. + */ +export interface LayoutCreatedMessage { + readonly type: "LAYOUT_CREATED"; + readonly clusterId: ClusterId; + /** SharedArrayBuffer (or ArrayBuffer fallback) with `[version, ...positions]`. */ + readonly buffer: SharedArrayBuffer | ArrayBuffer; + readonly nodeIds: readonly string[]; + /** + * Present when `buffer` is a flat-tier `FlatGraphBuffer` — positions + radii + + * colours in regions sized by this capacity, so the main thread builds all + * three views. Absent for a positions-only entity SharedArrayBuffer. + */ + readonly flatCapacity?: number; +} + +export interface LayoutPositionsMessage { + readonly type: "LAYOUT_POSITIONS"; + readonly clusterId: ClusterId; + /** Copied position data. Only sent when SharedArrayBuffer is unavailable. */ + readonly positions: Float32Array; +} + +export interface LayoutDestroyedMessage { + readonly type: "LAYOUT_DESTROYED"; + readonly clusterId: ClusterId; +} + +/** Which main-thread-held SharedArrayBuffer a {@link BufferRepublishedMessage} + * replaces. A discriminated union so new growable buffers (e.g. the EntityId + * map) slot in. */ +export type RepublishTarget = { + readonly kind: "layout"; + readonly clusterId: ClusterId; +}; + +/** + * A SharedArrayBuffer the main thread holds was re-allocated: it outgrew its in-place + * `maxByteLength` ceiling (or the platform can't grow shared buffers in place). The bytes were + * copied across, so nothing is lost (the main thread just swaps to `buffer` and + * re-attaches its Atomics version watcher). In-place growth sends nothing (the main thread + * already shares the same, now-larger buffer, and its length-tracking views auto-extend); + * this fires only on the rare re-allocation. `target` says which held buffer to replace. + */ +export interface BufferRepublishedMessage { + readonly type: "BUFFER_REPUBLISHED"; + readonly target: RepublishTarget; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + /** Records the new buffer can hold. */ + readonly capacity: number; +} + +/** + * The EntityIdx -> EntityId join map is backed by this SharedArrayBuffer (see worker/entity-id-buffer.ts). + * The worker is the sole writer; the main thread reads it on demand (hover/pick) to turn a + * rendered record's `entityIdx` back into its EntityId, so no per-entity data crosses the + * boundary. Sent on first publish and re-sent (same `type`, new `buffer`) whenever the buffer + * is re-allocated; in-place growth needs no message (the main thread shares the same, + * now-larger buffer and reads it fresh each time). + */ +export interface EntityIdMapMessage { + readonly type: "ENTITY_ID_MAP"; + readonly buffer: SharedArrayBuffer | ArrayBuffer; + /** Entities the buffer can hold. */ + readonly capacity: number; +} + +export interface ErrorMessage { + readonly type: "ERROR"; + readonly message: string; + readonly context?: string; +} + +/** + * Reply to {@link QueryEgoMessage}: the visible representative of each of the node's + * neighbors ({@link EgoTarget}) -- an entity dot or the cluster it collapses into. Neighbors + * not in the current view are omitted. + */ +export interface EgoResultMessage { + readonly type: "EGO_RESULT"; + readonly requestId: number; + readonly targets: readonly EgoTarget[]; +} + +/** + * Reply to {@link QueryHighwayLinksMessage}: the link entities the clicked + * highway lane aggregates, correlated by {@link requestId}. Empty when the lane + * has no aggregate identity (an individual edge, or an out-of-range id). + */ +export interface HighwayLinksResultMessage { + readonly type: "HIGHWAY_LINKS_RESULT"; + readonly requestId: number; + readonly linkEntityIdxs: readonly EntityIdx[]; +} + +export type WorkerToMainMessage = + | ReadyMessage + | StructureFrameMessage + | PositionsFrameMessage + | ModeChangedMessage + | EmbeddingClusteringNeededMessage + | LayoutCreatedMessage + | LayoutPositionsMessage + | LayoutDestroyedMessage + | BufferRepublishedMessage + | EntityIdMapMessage + | ErrorMessage + | EgoResultMessage + | HighwayLinksResultMessage; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts new file mode 100644 index 00000000000..87f5728ba4b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts @@ -0,0 +1,41 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { mulberry32, parkMillerRng } from "./random"; + +const draw = (next: () => number, count: number) => + Array.from({ length: count }, () => next()); + +describe("mulberry32", () => { + it("reproduces the same sequence for the same seed", () => { + expect(draw(mulberry32(12345), 8)).toEqual(draw(mulberry32(12345), 8)); + }); + + it("returns values in [0, 1)", () => { + const next = mulberry32(7); + for (let i = 0; i < 1000; i++) { + const value = next(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); + + it("diverges for different seeds", () => { + expect(draw(mulberry32(1), 4)).not.toEqual(draw(mulberry32(2), 4)); + }); +}); + +describe("parkMillerRng", () => { + it("reproduces the same sequence for the same seed", () => { + expect(draw(parkMillerRng(42), 8)).toEqual(draw(parkMillerRng(42), 8)); + }); + + it("returns values in (0, 1)", () => { + const next = parkMillerRng(99); + for (let i = 0; i < 1000; i++) { + const value = next(); + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(1); + } + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts new file mode 100644 index 00000000000..9b89c0bcb0b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts @@ -0,0 +1,34 @@ +/* eslint-disable id-length, no-bitwise */ +/** + * Deterministic seeded PRNGs, so layouts that depend on randomness (annealing, jitter, + * Louvain seeding) reproduce run to run with no dependence on Math.random. + */ + +/** + * mulberry32: a fast 32-bit PRNG returning a float in [0, 1). A given seed reproduces the + * exact sequence, so a layout anneals or lays out identically run to run. + */ +export function mulberry32(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) | 0; + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Park-Miller minimal-standard PRNG returning a float in (0, 1). A full-period + * multiplicative generator, used to seed Louvain so community detection is reproducible. + */ +export function parkMillerRng(seed: number): () => number { + let state = seed % 2147483647; + if (state <= 0) { + state += 2147483646; + } + return () => { + state = (state * 48271) % 2147483647; + return state / 2147483647; + }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts new file mode 100644 index 00000000000..2363292431c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts @@ -0,0 +1,106 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { decodeEntityId, ID_HEADER_BYTES } from "../entity-id-codec"; +import { EntityStore } from "./entity-store"; + +import type { EntityUuid, WebId } from "@blockprotocol/type-system"; + +const webId = "11111111-1111-4111-8111-111111111111" as WebId; + +const entityIdFor = (index: number) => + entityIdFromComponents( + webId, + `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid, + ); + +/** Decode the EntityId the store recorded for `idx`, reading its join-map SAB directly + * (the same on-demand read the main thread does). */ +const readMappedId = (store: EntityStore, idx: number) => + decodeEntityId(new Uint8Array(store.entityIdMap.raw, ID_HEADER_BYTES), idx); + +describe("EntityStore join map", () => { + it("writes each EntityId into the map the instant it is interned", () => { + const store = new EntityStore(); + const idA = entityIdFor(1); + const idB = entityIdFor(2); + + const [createdA, idxA] = store.tryInsert(idA); + const [createdB, idxB] = store.tryInsert(idB); + + expect(createdA).toBe(true); + expect(createdB).toBe(true); + expect(idxB).toBe(idxA + 1); + expect(readMappedId(store, idxA)).toBe(idA); + expect(readMappedId(store, idxB)).toBe(idB); + }); + + it("re-interning an EntityId returns the same idx, map untouched", () => { + const store = new EntityStore(); + const idA = entityIdFor(1); + const [, idx] = store.tryInsert(idA); + + const [createdAgain, idxAgain] = store.tryInsert(idA); + + expect(createdAgain).toBe(false); + expect(idxAgain).toBe(idx); + expect(readMappedId(store, idx)).toBe(idA); + }); + + it("grows the map past its initial capacity, preserving earlier entries", () => { + const store = new EntityStore(); + const first = entityIdFor(0); + store.tryInsert(first); + + // Exceed the initial capacity (4096) so the map grows at least once. + let lastId = first; + let lastIdx = 0; + for (let index = 1; index <= 5000; index++) { + lastId = entityIdFor(index); + const [, idx] = store.tryInsert(lastId); + lastIdx = idx; + } + + expect(store.size).toBe(5001); + expect(store.entityIdMap.capacity).toBeGreaterThanOrEqual(5001); + expect(readMappedId(store, 0)).toBe(first); // survived the grow(s) + expect(readMappedId(store, lastIdx)).toBe(lastId); + }); +}); + +describe("EntityStore roots", () => { + it("treats a freshly-interned entity as a frontier node (not a root)", () => { + const store = new EntityStore(); + const [, idx] = store.tryInsert(entityIdFor(1)); + expect(store.isRoot(idx)).toBe(false); + }); + + it("promotes an entity to a root, reporting the flip only once", () => { + const store = new EntityStore(); + const [, idx] = store.tryInsert(entityIdFor(1)); + + expect(store.setRoot(idx)).toBe(true); + expect(store.isRoot(idx)).toBe(true); + // Idempotent: promoting an existing root reports no flip and stays a root. + expect(store.setRoot(idx)).toBe(false); + expect(store.isRoot(idx)).toBe(true); + }); + + it("tracks root-ness independently per entity, including past the initial capacity", () => { + const store = new EntityStore(); + const [, idxA] = store.tryInsert(entityIdFor(1)); + + // Intern well past the initial capacity (4096) so the root bitset has to grow. + let highIdx = idxA; + for (let index = 2; index <= 5000; index++) { + const [, idx] = store.tryInsert(entityIdFor(index)); + highIdx = idx; + } + + expect(store.setRoot(highIdx)).toBe(true); + expect(store.isRoot(highIdx)).toBe(true); + expect(store.isRoot(idxA)).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts new file mode 100644 index 00000000000..69345520eda --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts @@ -0,0 +1,116 @@ +import { EntityIdBuffer } from "../buffers/entity-id-buffer"; +import { BitSet } from "../collections/bitset"; +import { Column } from "../collections/column"; +import { Interner } from "../collections/interner"; + +import type { EntityIdx, TypeSetIdx } from "../../ids"; +import type { RepublishHandler } from "../buffers/growable-buffer"; +import type { EntityId } from "@blockprotocol/type-system"; + +/** Starting slot count for the columns + the EntityId map (all grow geometrically). */ +const INITIAL_CAPACITY = 4096; + +/** + * Owns entity ID interning and per-entity columnar storage. + * + * Column indices are kept in sync with the interner: each new + * entity gets a push on every column, so EntityIdx works as the + * index into all of them. The {@link EntityIdBuffer} join map is just one more such + * column, written the instant an EntityIdx is assigned, so it is always current with the + * interner (no separate mirror/sync pass). + */ +export class EntityStore { + readonly #interner: Interner = new Interner(); + readonly #typeGroupIdx: Column = new Column( + Uint32Array, + INITIAL_CAPACITY, + ); + + readonly #labelIdx: Column = new Column(Int32Array, 4096); + + /** + * Query ROOTS as one bit per {@link EntityIdx} (1 bit/entity, auto-growing). A set bit means the + * entity came back as a root of the current query; a clear bit means it is a FRONTIER node -- a + * fetched link endpoint that is not itself a root, rendered greyed-out until expanded. Add-only + * within a worker's life (roots only grow as the frontier expands; a fresh query rebuilds the + * worker, and with it this set). + */ + readonly #roots: BitSet = BitSet.empty(INITIAL_CAPACITY); + + /** EntityIdx→EntityId SharedArrayBuffer join map. The worker passes `republish` (fired + * only on the rare re-allocation) and publishes {@link entityIdMap} once; reads are on + * the main thread, on demand. */ + readonly #entityIdMap: EntityIdBuffer; + + constructor(republish?: RepublishHandler) { + this.#entityIdMap = new EntityIdBuffer(INITIAL_CAPACITY, republish); + } + + get size(): number { + return this.#interner.size; + } + + /** The EntityIdx→EntityId join map SharedArrayBuffer (the worker publishes it to the main thread). */ + get entityIdMap(): EntityIdBuffer { + return this.#entityIdMap; + } + + /** + * Try to insert an entity. If new, allocates column slots and records its EntityId in + * the join map (growing the map geometrically, like the columns, so per-insert writes + * amortise O(1), never a grow-by-one per entity). + */ + tryInsert(entityId: EntityId): [created: boolean, idx: EntityIdx] { + const [created, idx] = this.#interner.tryIntern(entityId); + if (created) { + if (idx >= this.#entityIdMap.capacity) { + this.#entityIdMap.ensureCapacity( + Math.max(idx + 1, this.#entityIdMap.capacity * 2), + ); + } + this.#entityIdMap.setId(idx, entityId); + this.#typeGroupIdx.push(-1); + this.#labelIdx.push(-1); + } + return [created, idx]; + } + + tryGet(entityId: EntityId): EntityIdx | undefined { + return this.#interner.tryGet(entityId); + } + + getEntityId(idx: EntityIdx): EntityId { + return this.#interner.getValue(idx); + } + + setTypeGroup(entityIdx: EntityIdx, typeSetIdx: TypeSetIdx): void { + this.#typeGroupIdx.set(entityIdx, typeSetIdx); + } + + /** The type-set group an entity belongs to, or -1 if it's a link/unassigned. */ + getTypeGroup(entityIdx: EntityIdx): TypeSetIdx | -1 { + return this.#typeGroupIdx.get(entityIdx); + } + + setLabel(entityIdx: EntityIdx, labelIdx: number): void { + this.#labelIdx.set(entityIdx, labelIdx); + } + + /** Whether this entity is a query ROOT (vs a fetched-but-unexpanded FRONTIER node). O(1). */ + isRoot(entityIdx: EntityIdx): boolean { + return this.#roots.has(entityIdx); + } + + /** + * Promote an entity to a root. Returns whether it FLIPPED (was a frontier node), so the caller + * can recolour just that one record rather than re-styling the whole tier. + */ + setRoot(entityIdx: EntityIdx): boolean { + if (this.#roots.has(entityIdx)) { + return false; + } + + this.#roots.add(entityIdx); + return true; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts new file mode 100644 index 00000000000..c25021d8475 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts @@ -0,0 +1,164 @@ +import { LinkIdx } from "../../ids"; +import { Column } from "../collections/column"; + +import type { EntityIdx, TypeSetIdx } from "../../ids"; +import type { EntityId } from "@blockprotocol/type-system"; + +export interface LinkEndpoint { + readonly linkIdx: number; + readonly otherIdx: EntityIdx; + readonly typeSetIdx: TypeSetIdx; + readonly direction: "out" | "in"; +} + +/** + * Owns link storage: columnar arrays for left/right endpoints, + * link type, and the link's own entity index. Also tracks pending + * links whose endpoints haven't been ingested yet (for frontier + * detection). + */ +export class LinkStore { + readonly #leftIdx: Column = new Column( + Int32Array, + 1024, + ); + + readonly #rightIdx: Column = new Column( + Int32Array, + 1024, + ); + + readonly #typeIdx: Column = new Column( + Uint32Array, + 1024, + ); + + readonly #entityIdIdx: Column = new Column( + Uint32Array, + 1024, + ); + + readonly #pendingByEndpoint: Map = new Map(); + + // Adjacency index: entityIdx -> list of link indices touching that entity. + readonly #adjacency: Map = new Map(); + + get count(): number { + return this.#leftIdx.length; + } + + /** + * Record a link. Endpoints are -1 when the target entity + * hasn't been ingested yet (frontier case). + */ + insert( + leftIdx: EntityIdx | -1, + rightIdx: EntityIdx | -1, + typeSetIdx: TypeSetIdx, + linkEntityIdx: EntityIdx, + ): LinkIdx { + this.#leftIdx.push(leftIdx); + this.#rightIdx.push(rightIdx); + this.#typeIdx.push(typeSetIdx); + this.#entityIdIdx.push(linkEntityIdx); + + const linkIdx = LinkIdx(this.#leftIdx.length - 1); + + if (leftIdx !== -1) { + this.#addToAdjacency(leftIdx, linkIdx); + } + if (rightIdx !== -1) { + this.#addToAdjacency(rightIdx, linkIdx); + } + + return linkIdx; + } + + #addToAdjacency(entityIdx: number, linkIdx: LinkIdx): void { + let list = this.#adjacency.get(entityIdx); + if (!list) { + list = []; + this.#adjacency.set(entityIdx, list); + } + list.push(linkIdx); + } + + addPending(endpointId: EntityId, linkIdx: LinkIdx): void { + const pending = this.#pendingByEndpoint.get(endpointId) ?? []; + pending.push(linkIdx); + this.#pendingByEndpoint.set(endpointId, pending); + } + + takePending(endpointId: EntityId): LinkIdx[] | undefined { + const pending = this.#pendingByEndpoint.get(endpointId); + if (pending) { + this.#pendingByEndpoint.delete(endpointId); + } + return pending; + } + + resolveEndpoint( + linkIdx: LinkIdx, + side: "left" | "right", + entityIdx: EntityIdx, + ): void { + if (side === "left") { + this.#leftIdx.set(linkIdx, entityIdx); + } else { + this.#rightIdx.set(linkIdx, entityIdx); + } + this.#addToAdjacency(entityIdx, linkIdx); + } + + getLeft(linkIdx: number) { + return this.#leftIdx.get(linkIdx); + } + + getRight(linkIdx: number) { + return this.#rightIdx.get(linkIdx); + } + + getTypeSetIdx(linkIdx: number) { + return this.#typeIdx.get(linkIdx); + } + + /** The link's OWN entity index (a link is an entity), for resolving a picked edge. */ + getEntityIdx(linkIdx: number): EntityIdx { + return this.#entityIdIdx.get(linkIdx); + } + + /** + * Collect all links touching an entity, with direction info. + * O(degree) via the adjacency index. + */ + linksForEntity(entityIdx: EntityIdx): LinkEndpoint[] { + const linkIdxs = this.#adjacency.get(entityIdx); + if (!linkIdxs) { + return []; + } + + const result: LinkEndpoint[] = []; + for (const linkIdx of linkIdxs) { + const left = this.#leftIdx.get(linkIdx); + const right = this.#rightIdx.get(linkIdx); + + if (left === entityIdx && right !== -1) { + result.push({ + linkIdx, + otherIdx: right, + typeSetIdx: this.#typeIdx.get(linkIdx), + direction: "out", + }); + } else if (right === entityIdx && left !== -1) { + result.push({ + linkIdx, + otherIdx: left, + typeSetIdx: this.#typeIdx.get(linkIdx), + direction: "in", + }); + } + } + + return result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts new file mode 100644 index 00000000000..0f52ff2a5c4 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts @@ -0,0 +1,255 @@ +/** + * Per-entity property FEATURES for distinctive-feature cluster naming. + * + * The worker holds entity property VALUES (shipped on {@link IngestEntity}) but never + * needs their full fidelity: naming a cluster only needs to know which entities SHARE a + * given `(property, value)`. So each scalar property value is reduced to a "feature" -- a + * `(baseUrl, formatted-value)` pair -- and interned to a small integer, exactly like the + * entity/type interners elsewhere. An entity then stores just the sorted list of its + * feature indices, and the namer ({@link nameClustersByDistinctiveFeatures}) tallies + * those across a cluster's members. + * + * "Scalars-plus": strings/numbers/booleans become a value feature; arrays collapse to a + * count summary and nested objects to a presence token (weaker signals, but they cost + * almost nothing and occasionally disambiguate). Property display TITLES are registered + * separately (from {@link PropertySchemaEntry}) so a label reads "Destination = ..." and + * not a raw base URL. + * + * Numbers and ISO dates ADDITIONALLY keep their RAW value (per entity, keyed by an interned + * property base URL) so the namer can bucket them into per-subdivision quantile RANGES + * ("Quantity 100–500"). An exact value feature is still produced too, so a low-cardinality + * number (a status `= 0`) keeps naming by its exact value while a high-cardinality one + * (every quantity distinct, so no exact value is ever common) is named by its range instead. + */ +import { Interner } from "../collections/interner"; + +import type { EntityIdx } from "../../ids"; +import type { PropertySchemaEntry } from "../protocol"; +import type { PropertyObject } from "@blockprotocol/type-system"; + +/** Interned index of a distinct `(baseUrl, formatted-value)` feature. */ +export type FeatureIdx = number; + +/** Interned index of a property base URL that carries numeric/date values. */ +export type NumericKeyIdx = number; + +/** Whether a numeric property reads as a plain number or an (epoch-ms) date. */ +export type NumericKind = "number" | "date"; + +/** Cap a value's serialized length so a free-text property can't bloat the interner. */ +const MAX_VALUE_CHARS = 64; + +/** + * ISO-8601 date / datetime: `YYYY-MM-DD` with an optional time and zone. Deliberately + * strict so a plain numeric string or arbitrary text is NOT mistaken for a date (a far + * looser `Date.parse` would treat "1" or "May" as dates). + */ +const ISO_DATE_RE = + /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/u; + +interface FeatureInfo { + readonly baseUrl: string; + /** The value as it appears in a label: `"foo"`, `123`, `true`, `3 items`, `present`. */ + readonly display: string; +} + +/** What a feature renders to in a label. */ +export interface FeatureLabel { + readonly baseUrl: string; + readonly title: string; + readonly display: string; +} + +/** A raw numeric reading: a plain number, or a date as epoch milliseconds. */ +interface NumericReading { + readonly value: number; + readonly kind: NumericKind; +} + +function truncate(value: string): string { + return value.length > MAX_VALUE_CHARS + ? `${value.slice(0, MAX_VALUE_CHARS)}…` + : value; +} + +/** + * Reduce a property value to its label display form, or undefined when it carries no + * usable signal (empty string, empty array, null). Strings are quoted; numbers/booleans + * are bare; arrays summarise to a count and nested objects to a presence token. + */ +function formatFeatureValue(value: unknown): string | undefined { + if (typeof value === "string") { + // Collapse whitespace runs (and trim): folds fixed-width padding like "CNTM " into + // "CNTM" so padded/unpadded values share a feature, and keeps a value on one label line. + const collapsed = value.replace(/\s+/gu, " ").trim(); + return collapsed.length === 0 ? undefined : `"${truncate(collapsed)}"`; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return value.length === 0 + ? undefined + : `${value.length} item${value.length === 1 ? "" : "s"}`; + } + if (value !== null && typeof value === "object") { + return "present"; + } + return undefined; +} + +/** + * The raw numeric value (for range bucketing) of a property value, or undefined when it is + * not a finite number or an ISO date. Dates collapse to epoch milliseconds so numbers and + * dates share one ordered axis. + */ +function numericReading(value: unknown): NumericReading | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? { value, kind: "number" } : undefined; + } + if (typeof value === "string" && ISO_DATE_RE.test(value)) { + const ms = Date.parse(value); + return Number.isNaN(ms) ? undefined : { value: ms, kind: "date" }; + } + return undefined; +} + +/** + * Title-case the slug of a property-type base URL (".../property-type//") as a + * fallback when no registered title is available, so a label never shows a raw URL. + */ +function slugTitleFromBaseUrl(baseUrl: string): string { + const slug = /\/property-type\/(?[^/]+)\/?$/.exec(baseUrl)?.groups + ?.slug; + if (!slug) { + return baseUrl; + } + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export class PropertyStore { + readonly #features: Interner = new Interner(); + readonly #featureInfo: FeatureInfo[] = []; + /** Per-entity sorted, de-duplicated feature indices, indexed by EntityIdx (dense, additive). */ + readonly #entityFeatures: (Int32Array | undefined)[] = []; + /** baseUrl -> human title, from {@link PropertySchemaEntry}. */ + readonly #titles: Map = new Map(); + + /** baseUrl -> NumericKeyIdx, for properties that carry numeric/date values. */ + readonly #numericKeys: Interner = new Interner(); + /** Parallel to the interner: per-key base URL and kind (number vs date). */ + readonly #numericKeyBaseUrl: string[] = []; + readonly #numericKeyKind: NumericKind[] = []; + /** + * Per-entity raw numeric readings as two parallel arrays (key indices + values), indexed + * by EntityIdx. Kept RAW (not interned) precisely because the namer buckets them into + * ranges computed from the live distribution of a subdivision's siblings -- a value's + * meaning ("low" vs "high") is relative, so it cannot be pre-interned like an exact value. + */ + readonly #entityNumericKeys: (Int32Array | undefined)[] = []; + readonly #entityNumericValues: (Float64Array | undefined)[] = []; + + /** Register property display titles (additive; later batches add, never overwrite). */ + registerTitles(entries: readonly PropertySchemaEntry[]): void { + for (const { baseUrl, title } of entries) { + if (title && !this.#titles.has(baseUrl)) { + this.#titles.set(baseUrl, title); + } + } + } + + /** Human title for a base URL: the registered property-type title, else a slug fallback. */ + title(baseUrl: string): string { + return this.#titles.get(baseUrl) ?? slugTitleFromBaseUrl(baseUrl); + } + + /** + * Reduce an entity's properties to its interned scalar features AND its raw numeric/date + * readings. No-op when the entity has no labelable property (a link, or only empty/complex + * values). + */ + ingest(entityIdx: EntityIdx, properties: PropertyObject | undefined): void { + if (!properties) { + return; + } + + const featureIdxs = new Set(); + const numericKeyIdxs: NumericKeyIdx[] = []; + const numericValues: number[] = []; + + for (const [baseUrl, value] of Object.entries(properties)) { + const display = formatFeatureValue(value); + if (display !== undefined) { + const [created, featureIdx] = this.#features.tryIntern( + `${baseUrl}\u0000${display}`, + ); + if (created) { + this.#featureInfo.push({ baseUrl, display }); + } + featureIdxs.add(featureIdx); + } + + const reading = numericReading(value); + if (reading) { + const [created, keyIdx] = this.#numericKeys.tryIntern(baseUrl); + if (created) { + this.#numericKeyBaseUrl[keyIdx] = baseUrl; + this.#numericKeyKind[keyIdx] = reading.kind; + } + numericKeyIdxs.push(keyIdx); + numericValues.push(reading.value); + } + } + + if (featureIdxs.size > 0) { + this.#entityFeatures[entityIdx] = Int32Array.from( + [...featureIdxs].sort((left, right) => left - right), + ); + } + if (numericKeyIdxs.length > 0) { + this.#entityNumericKeys[entityIdx] = Int32Array.from(numericKeyIdxs); + this.#entityNumericValues[entityIdx] = Float64Array.from(numericValues); + } + } + + /** An entity's feature indices, or undefined if it has none. */ + featuresOf(entityIdx: EntityIdx): Int32Array | undefined { + return this.#entityFeatures[entityIdx]; + } + + /** The base URL, title, and display value a feature renders to in a label. */ + describe(featureIdx: FeatureIdx): FeatureLabel | undefined { + const info = this.#featureInfo[featureIdx]; + if (!info) { + return undefined; + } + return { + baseUrl: info.baseUrl, + title: this.title(info.baseUrl), + display: info.display, + }; + } + + /** An entity's numeric-property key indices (parallel to {@link numericValuesOf}). */ + numericKeysOf(entityIdx: EntityIdx): Int32Array | undefined { + return this.#entityNumericKeys[entityIdx]; + } + + /** An entity's raw numeric values (numbers, or dates as epoch ms; date-keyed by kind). */ + numericValuesOf(entityIdx: EntityIdx): Float64Array | undefined { + return this.#entityNumericValues[entityIdx]; + } + + /** The base URL of a numeric property key, or undefined if unknown. */ + numericBaseUrl(keyIdx: NumericKeyIdx): string | undefined { + return this.#numericKeyBaseUrl[keyIdx]; + } + + /** Whether a numeric property key reads as a plain number or a date. */ + numericKind(keyIdx: NumericKeyIdx): NumericKind { + return this.#numericKeyKind[keyIdx] ?? "number"; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts new file mode 100644 index 00000000000..f1f14993f65 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts @@ -0,0 +1,148 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { TypeRegistry } from "./type-registry"; + +import type { TypeSchemaEntry } from "../protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +const url = (slug: string): VersionedUrl => + `https://example.com/types/entity-type/${slug}/v/1` as VersionedUrl; + +describe("TypeRegistry root resolution", () => { + it("resolves a child's root when the child is interned BEFORE its parent", () => { + const customer = url("customer"); + const company = url("company"); + + // Customer is listed first, so processing its `allOfRefs` interns `company` + // as a bare ref — giving the parent a HIGHER idx than the child. The roots + // computation must not depend on that ordering. + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + const customerIdx = registry.intern(customer); + const companyIdx = registry.intern(company); + + expect(customerIdx).toBeLessThan(companyIdx); + expect(registry.get(companyIdx)?.rootIdxs).toEqual([companyIdx]); + expect(registry.get(customerIdx)?.rootIdxs).toEqual([companyIdx]); + }); + + it("resolves the SAME root for siblings so they bucket together", () => { + const customer = url("customer"); + const supplier = url("supplier"); + const company = url("company"); + + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + const companyIdx = registry.intern(company); + expect(registry.get(registry.intern(customer))?.rootIdxs).toEqual([ + companyIdx, + ]); + expect(registry.get(registry.intern(supplier))?.rootIdxs).toEqual([ + companyIdx, + ]); + }); + + it("resolves a multi-level chain to the topmost ancestor", () => { + const customer = url("customer"); + const company = url("company"); + const actor = url("actor"); + + // Transitive over-approximation (child points at ALL ancestors) plus a + // deeper chain — the topmost parentless type must win. + const schemas: TypeSchemaEntry[] = [ + { url: customer, title: "Customer", allOfRefs: [company, actor] }, + { url: company, title: "Company", allOfRefs: [actor] }, + { url: actor, title: "Actor", allOfRefs: [] }, + ]; + + const registry = new TypeRegistry(); + registry.registerAll(schemas); + + const actorIdx = registry.intern(actor); + expect(registry.get(registry.intern(customer))?.rootIdxs).toEqual([ + actorIdx, + ]); + }); +}); + +describe("TypeRegistry colour slots", () => { + it("assigns slots sorted by base URL within a batch", () => { + const customer = url("customer"); + const supplier = url("supplier"); + const company = url("company"); + + // Arrival order (customer, supplier, company) differs from sorted order + // (company, customer, supplier) — the slot follows the SORT, not arrival. + const registry = new TypeRegistry(); + registry.registerAll([ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]); + + expect(registry.colorSlot(registry.intern(company))).toBe(0); + expect(registry.colorSlot(registry.intern(customer))).toBe(1); + expect(registry.colorSlot(registry.intern(supplier))).toBe(2); + }); + + it("appends new batches without re-slotting existing types", () => { + const company = url("company"); + const person = url("person"); + const actor = url("actor"); + + const registry = new TypeRegistry(); + registry.registerAll([{ url: company, title: "Company", allOfRefs: [] }]); + const companySlot = registry.colorSlot(registry.intern(company)); + + registry.registerAll([ + { url: person, title: "Person", allOfRefs: [] }, + { url: actor, title: "Actor", allOfRefs: [] }, + ]); + + // The first batch keeps its slot; the second is appended, sorted within + // itself (actor < person), so existing colours never shift on expansion. + expect(registry.colorSlot(registry.intern(company))).toBe(companySlot); + expect(registry.colorSlot(registry.intern(actor))).toBe(1); + expect(registry.colorSlot(registry.intern(person))).toBe(2); + }); + + it("gives identical slots regardless of arrival order (reload stability)", () => { + const customer = url("customer"); + const supplier = url("supplier"); + const company = url("company"); + + const forward = new TypeRegistry(); + forward.registerAll([ + { url: customer, title: "Customer", allOfRefs: [company] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: company, title: "Company", allOfRefs: [] }, + ]); + + const reversed = new TypeRegistry(); + reversed.registerAll([ + { url: company, title: "Company", allOfRefs: [] }, + { url: supplier, title: "Supplier", allOfRefs: [company] }, + { url: customer, title: "Customer", allOfRefs: [company] }, + ]); + + for (const typeUrl of [company, customer, supplier]) { + expect(forward.colorSlot(forward.intern(typeUrl))).toBe( + reversed.colorSlot(reversed.intern(typeUrl)), + ); + } + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts new file mode 100644 index 00000000000..3316fa35b99 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts @@ -0,0 +1,251 @@ +import { extractBaseUrl } from "@blockprotocol/type-system"; + +import { BitSet } from "../collections/bitset"; +import { Interner } from "../collections/interner"; + +import type { TypeIdx } from "../../ids"; +import type { TypeSchemaEntry } from "../protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +export interface TypeInfo { + readonly idx: TypeIdx; + readonly url: VersionedUrl; + readonly title: string; + /** For a link type, its inverse (target -> source) title; used to label a reverse lane. */ + readonly inverseTitle?: string; + readonly icon?: string; + readonly parentIdxs: readonly TypeIdx[]; + readonly ancestorClosure: BitSet; + readonly depth: number; + readonly rootIdxs: readonly TypeIdx[]; +} + +/** + * Owns the mapping from VersionedUrl to TypeIdx and the + * per-type metadata (title, icon, parent refs, ancestor closure). + */ +export class TypeRegistry { + readonly #interner: Interner = new Interner(); + readonly #types: (TypeInfo | undefined)[] = []; + /** + * Stable colour slot per type, assigned sorted-by-base-URL within each + * registration batch and never reassigned (append-only). A type's colour is + * derived from its slot, so it is identical across reloads (the slot depends + * on the URL, not on arrival order) and unchanged when a later batch -- a new + * page or a frontier expansion -- registers more types. + */ + readonly #colorSlots: Map = new Map(); + #nextColorSlot = 0; + + get size(): number { + return this.#interner.size; + } + + intern(url: VersionedUrl): TypeIdx { + return this.#interner.intern(url); + } + + get(idx: TypeIdx): TypeInfo | undefined { + return this.#types[idx]; + } + + getUrl(idx: TypeIdx): VersionedUrl | undefined { + try { + return this.#interner.getValue(idx); + } catch { + return undefined; + } + } + + /** + * Debug: one line per registered type, idx, title, resolved parents/roots. + * A parent that was interned (referenced via `allOf`) but never given a + * schema shows as `#(unreg)`, the signature of a missing ancestor type, + * which makes its descendants resolve to empty `rootIdxs`. + */ + debugDump(): string { + const name = (idx: TypeIdx): string => + this.#types[idx]?.title ?? `#${idx}(unreg)`; + const lines: string[] = []; + for (const info of this.#types) { + if (!info) { + continue; + } + const parents = info.parentIdxs.map(name).join(", "); + const roots = info.rootIdxs.map(name).join(", "); + lines.push( + `#${info.idx} "${info.title}" parents=[${parents}] roots=[${roots}]`, + ); + } + return lines.join("\n"); + } + + /** + * Register all type schemas. Two passes: first intern everything + * (so parent refs resolve), then build ancestor closures. + */ + registerAll(schemas: readonly TypeSchemaEntry[]): void { + const newlyRegistered: TypeIdx[] = []; + + for (const schema of schemas) { + const idx = this.#interner.intern(schema.url); + + // Skip if this type already has TypeInfo registered. + if (this.#types[idx]) { + continue; + } + + newlyRegistered.push(idx); + const parentIdxs = schema.allOfRefs.map((ref) => + this.#interner.intern(ref), + ); + + this.#types[idx] = { + idx, + url: schema.url, + title: schema.title, + inverseTitle: schema.inverseTitle, + icon: schema.icon, + parentIdxs, + ancestorClosure: BitSet.empty(this.#interner.size), + depth: 0, + rootIdxs: [], + }; + } + + if (newlyRegistered.length > 0) { + this.#assignColorSlots(newlyRegistered); + this.#computeClosures(); + } + } + + /** + * Assign a stable colour slot to each newly-registered type. Sorting the + * batch by base URL makes the slot order deterministic regardless of the order + * types arrived in this session, so the same type gets the same slot -- and so + * the same colour -- on every reload. Slots are append-only: types from an + * earlier batch keep theirs, so a later page or frontier expansion never + * re-colours what is already on screen. + */ + #assignColorSlots(newlyRegistered: readonly TypeIdx[]): void { + const sorted = [...newlyRegistered].sort((left, right) => { + const leftUrl = extractBaseUrl(this.#types[left]!.url); + const rightUrl = extractBaseUrl(this.#types[right]!.url); + if (leftUrl < rightUrl) { + return -1; + } + return leftUrl > rightUrl ? 1 : 0; + }); + for (const idx of sorted) { + this.#colorSlots.set(idx, this.#nextColorSlot); + this.#nextColorSlot += 1; + } + } + + /** Stable colour slot for a type, or undefined if not yet registered. */ + colorSlot(idx: TypeIdx): number | undefined { + return this.#colorSlots.get(idx); + } + + #computeClosures(): void { + const universeSize = this.#interner.size; + + // Build closure bottom-up: each type's closure is itself + union of parent closures. + // Since the type hierarchy is a DAG, iterate until stable. + const closures = new Map>(); + const depths = new Map(); + const roots = new Map(); + + for (const info of this.#types) { + if (!info) { + continue; + } + + const closure = BitSet.fromBit(universeSize, info.idx); + closures.set(info.idx, closure); + depths.set(info.idx, 0); + roots.set(info.idx, []); + } + + // Fixed-point iteration (typically converges in 2 to 3 passes for shallow hierarchies). + let changed = true; + while (changed) { + changed = false; + + for (const info of this.#types) { + if (!info) { + continue; + } + + const current = closures.get(info.idx)!; + let merged = current; + + for (const parentIdx of info.parentIdxs) { + const parentClosure = closures.get(parentIdx); + if (parentClosure) { + const next = merged.or(parentClosure); + if (next.cardinality > merged.cardinality) { + merged = next; + changed = true; + } + } + } + + closures.set(info.idx, merged); + + // Depth: 1 + max parent depth. + if (info.parentIdxs.length > 0) { + const parentDepth = Math.max( + ...info.parentIdxs.map((parentIdx) => depths.get(parentIdx) ?? 0), + ); + const newDepth = parentDepth + 1; + if (newDepth > (depths.get(info.idx) ?? 0)) { + depths.set(info.idx, newDepth); + changed = true; + } + } + + // Roots: a parentless type is its own root; otherwise it inherits the + // union of its parents' roots. This must live inside the fixed-point. + // A single forward pass over idx order would read a parent's roots + // before they were computed whenever the child was interned first, + // and parents are interned lazily from `allOfRefs`, so child-before- + // parent is the common case. That zeroed the child's roots and dropped + // it into the catch-all "unknown" bucket (the Customer/Supplier bug). + if (info.parentIdxs.length === 0) { + if (roots.get(info.idx)!.length === 0) { + roots.set(info.idx, [info.idx]); + changed = true; + } + } else { + const rootSet = new Set(roots.get(info.idx)); + const before = rootSet.size; + for (const parentIdx of info.parentIdxs) { + for (const rootIdx of roots.get(parentIdx) ?? []) { + rootSet.add(rootIdx); + } + } + if (rootSet.size > before) { + roots.set(info.idx, [...rootSet]); + changed = true; + } + } + } + } + + // Write back. + for (let i = 0; i < this.#types.length; i++) { + const info = this.#types[i]; + if (!info) { + continue; + } + + this.#types[i] = { + ...info, + ancestorClosure: closures.get(info.idx) ?? info.ancestorClosure, + depth: depths.get(info.idx) ?? 0, + rootIdxs: roots.get(info.idx) ?? [], + }; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts new file mode 100644 index 00000000000..d9d4fda43e7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts @@ -0,0 +1,128 @@ +import { ClusterId, TypeSetKey } from "../../ids"; +import { BitSet } from "../collections/bitset"; +import { Interner } from "../collections/interner"; + +import type { EntityIdx, TypeIdx, TypeSetIdx } from "../../ids"; +import type { ReadonlySortedSet } from "../collections/readonly-sorted-set"; +import type { TypeRegistry } from "./type-registry"; + +export class TypeSetGroup { + readonly key: TypeSetKey; + readonly idx: TypeSetIdx; + readonly directTypeIdxs: ReadonlySortedSet; + readonly standaloneClusterId: ClusterId; + + #entityIdxs: EntityIdx[] = []; + #closure: BitSet; + #version = 0; + #assignedClusterId: ClusterId; + #isStandalone = false; + + constructor( + key: TypeSetKey, + idx: TypeSetIdx, + directTypeIdxs: ReadonlySortedSet, + typeUniverseSize: number, + ) { + this.key = key; + this.idx = idx; + this.directTypeIdxs = directTypeIdxs; + this.standaloneClusterId = ClusterId(`cluster:type:${key}`); + this.#assignedClusterId = this.standaloneClusterId; + this.#closure = BitSet.empty(typeUniverseSize); + } + + get count(): number { + return this.#entityIdxs.length; + } + + get entityIdxs(): readonly EntityIdx[] { + return this.#entityIdxs; + } + + get closure(): BitSet { + return this.#closure; + } + + get version(): number { + return this.#version; + } + + get assignedClusterId(): ClusterId { + return this.#assignedClusterId; + } + + set assignedClusterId(id: ClusterId) { + this.#assignedClusterId = id; + } + + get isStandalone(): boolean { + return this.#isStandalone; + } + + set isStandalone(value: boolean) { + this.#isStandalone = value; + } + + addEntity(entityIdx: EntityIdx): void { + this.#entityIdxs.push(entityIdx); + this.#version++; + } + + /** Recompute the closure from the type registry's ancestor closures. */ + recomputeClosure(types: TypeRegistry): void { + let closure = BitSet.empty(types.size); + + for (const typeIdx of this.directTypeIdxs) { + const info = types.get(typeIdx); + if (info) { + closure = closure.or(info.ancestorClosure); + } + } + + this.#closure = closure; + } +} + +/** + * Manages type-set groups: collections of entities that share + * the same set of direct entity types. + */ +export class TypeSetStore { + readonly #interner: Interner = new Interner(); + readonly #groups: Map = new Map(); + + get size(): number { + return this.#groups.size; + } + + get(key: TypeSetKey): TypeSetGroup | undefined { + return this.#groups.get(key); + } + + getByIdx(idx: TypeSetIdx): TypeSetGroup | undefined { + const key = this.#interner.getValue(idx); + return this.#groups.get(key); + } + + getOrCreate( + directTypeIdxs: ReadonlySortedSet, + typeUniverseSize: number, + ): TypeSetGroup { + const key = TypeSetKey(directTypeIdxs.items.join(",")); + const existing = this.#groups.get(key); + if (existing) { + return existing; + } + + const idx = this.#interner.intern(key); + const group = new TypeSetGroup(key, idx, directTypeIdxs, typeUniverseSize); + this.#groups.set(key, group); + + return group; + } + + *[Symbol.iterator](): IterableIterator { + yield* this.#groups.values(); + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/SPEC-graph-viz-v2.md b/apps/hash-frontend/src/pages/shared/graph-visualizer/SPEC-graph-viz-v2.md new file mode 100644 index 00000000000..2d3885a2e44 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/SPEC-graph-viz-v2.md @@ -0,0 +1,3389 @@ +# Graph Visualization v2: Hierarchical Semantic Zoom + +## Status + +**Draft** — all sections concrete. Oracle validation pass complete; corrections integrated. + +## Problem + +The current graph visualization (Sigma.js + Graphology + ForceAtlas2) renders every entity as a flat node on a single canvas. It works for small graphs (~10k nodes) but breaks down at scale. With 3M+ entities: + +- ForceAtlas2 layout can't converge +- Sigma.js rendering saturates around 100k nodes +- The flat view becomes a meaningless hairball +- Users must manually configure sizing, filtering, and layout + +We need a visualization that makes millions of entities tractable through structure, not configuration. + +## Core Architecture + +Four cleanly separated concerns: + +1. **Semantic partition**: every loaded non-link entity belongs to exactly one atomic cluster, derived from its canonical type set. +2. **Display hierarchy**: a rooted roll-up tree over those atomic clusters, used for circle packing and semantic zoom. +3. **Visible LOD cut**: for a given viewport, choose a cut through the display tree. Edge aggregation is the quotient graph induced by that cut. +4. **Local detail layout**: only for visible expanded clusters; cached, versioned, and independent of the macro layout. + +Invariants: + +- No loaded entity is rendered twice. +- Internal cluster count equals the sum of children. +- Aggregated edge counts equal the sum of underlying link entities. +- Zoom changes the visible cut, not the underlying clustering. + +## Scale-Adaptive Strategy + +The visualization operates in one of three explicit modes. The system picks the mode automatically; the user never configures this. Thresholds are based on **loaded non-link entity count** (not total records including links). + +```ts +type VizMode = "flat-force" | "community-force" | "hierarchical-lod"; +``` + +### `flat-force` (< ~200 nodes): Flat Force-Directed + +No clustering hierarchy. All nodes rendered directly. The core architecture invariants (semantic partition, display hierarchy, LOD cut) do NOT apply in this mode. + +1. Run **Louvain community detection** on the link graph → community assignments. Use deterministic seeding for stability. (Cheap at this scale.) +2. Place community centers on a circle or small circle packing. +3. Place entities within their community's region (phyllotaxis or jitter). +4. Run **force-directed layout** (ForceAtlas2) from these community-seeded initial positions → converges fast, spatial layout already reflects link structure. +5. Color by entity type. Community structure is reflected spatially, not by explicit cluster bubbles. + +### `community-force` (~200-1000 nodes): Community-Colored Force-Directed + +Same layout strategy, with visual grouping. The core architecture invariants do NOT apply in this mode. + +1. Run Louvain community detection with deterministic seeding. +2. Community-seeded force-directed layout (same as `flat-force`). +3. Add subtle visual grouping: colored background regions behind communities, or convex hull outlines. +4. Color by entity type, with community structure visible in spatial layout. +5. Optionally show community labels on hover. + +### `hierarchical-lod` (> ~1000 nodes): Full Hierarchical Clustering with LOD + +The full type-set clustering + display hierarchy + semantic zoom system described in the rest of this spec. All four core architecture invariants apply. Louvain is too expensive at 3M nodes in a web worker; the type system provides the semantic clustering axis instead. + +### Mode transitions + +Thresholds use **hysteresis** to prevent mode flapping when node count fluctuates around a boundary (e.g. during filtering or progressive loading): + +```ts +interface VizConfig { + // ... existing fields ... + + // --- Scale thresholds --- + flatLayoutMaxNodes: number; // e.g. 200, enter flat-force below this + flatLayoutExitNodes: number; // e.g. 250, exit flat-force above this + communityColorMaxNodes: number; // e.g. 1000, enter community-force below this + communityColorExitNodes: number; // e.g. 1200, exit to hierarchical-lod above this +} +``` + +When transitioning from `flat-force`/`community-force` to `hierarchical-lod`: + +- Animate entity positions → cluster centroids. +- Individual entities fade into their cluster bubbles. + +When transitioning downward: + +- Cluster bubbles dissolve into individual entity positions. +- Force layout runs from cluster-centroid seeded positions. + +## Rendering: Deck.gl + +[Deck.gl](https://deck.gl/) with `OrthographicView` (non-geospatial 2D). + +Why Deck.gl over Sigma.js: + +- Handles millions of points natively (GPU-accelerated WebGL) +- Layer-based architecture maps directly to LOD: different layers for different zoom levels +- Built-in smooth zoom/pan with viewport transitions +- Picking (click/hover detection) at scale via GPU color picking +- React bindings (`@deck.gl/react`) + +Layer structure: + +- `ScatterplotLayer` — cluster bubbles (radius = √count × scale factor) +- `ScatterplotLayer` — individual entity nodes (when zoomed into a cluster) +- `TextLayer` — cluster labels (type name + count) +- `TextLayer` — entity labels (when zoomed in) +- `LineLayer` — aggregated inter-cluster edges (multiple per link type) +- `LineLayer` — individual entity edges (when zoomed in) +- `IconLayer` — entity type icons on individual nodes +- `ScatterplotLayer` — frontier nodes (desaturated, distinct visual treatment) + +## Shared Type Definitions + +Use compact integer indices and typed arrays in the worker. Do not hold 3M React objects or send them to the main thread. + +```ts +type EntityIdx = number; +type LinkIdx = number; +type TypeIdx = number; + +type EntityId = string; +type VersionedUrl = string; +type TypeSetKey = string; // sorted, comma-joined TypeIdx values +type ClusterId = string; + +type ClusterKind = + | "root" + | "family" // roll-up intermediate node + | "type-set" // atomic cluster from type-set grouping + | "other" // catch-all for rare type combinations + | "community" // sub-cluster from link-structure detection + | "embedding" // sub-cluster from embedding k-means + | "entity-bucket"; // leaf bucket for entity-level display + +interface VizConfig { + // --- Scale thresholds --- + flatLayoutMaxNodes: number; // e.g. 200 + communityColorMaxNodes: number; // e.g. 1000 + + // --- Clustering thresholds --- + minStandaloneTypeSet: number; // e.g. 25 + mergeJaccardMin: number; // e.g. 0.25 + mergeSubsetJaccardMin: number; // e.g. 0.15 + maxChildrenPerParent: number; // e.g. 64 + + // --- Sub-clustering --- + subclusterAboveCount: number; // e.g. 500 + entityRevealMax: number; // e.g. 500 + forceMaxNodes: number; // e.g. 2_000 + communityWorkerNodeCap: number; // e.g. 50_000 + communityMinSize: number; // e.g. 20 + communityMaxSize: number; // e.g. 500 + + // --- Semantic zoom thresholds (screen-space px) --- + openChildrenRadiusPx: number; // e.g. 90 + closeChildrenRadiusPx: number; // e.g. 65 + openEntitiesRadiusPx: number; // e.g. 240 + closeEntitiesRadiusPx: number; // e.g. 180 + + // --- Embedding subdivision --- + embeddingProjectionDims: number; // e.g. 128 + embeddingMaxK: number; // e.g. 32 + embeddingTargetLeafFillRatio: number; // e.g. 0.75 + embeddingClientNodeCap: number; // e.g. 25_000 + embeddingMinConcentration: number; // e.g. 0.3 (rho threshold for vec2slug) + + // --- Bubble ports --- + minPortSpacingPx: number; // e.g. 12 + maxPortsPerCluster: number; // e.g. 24 + portPaddingWorld: number; // e.g. 4 + portTension: number; // e.g. 0.4 (Bezier control point distance as fraction of edge length) + + // --- Render budgets --- + maxRenderedClusters: number; // e.g. 4_000 + maxRenderedEntities: number; // e.g. 5_000 + maxRenderedEdges: number; // e.g. 10_000 + maxParallelEdgeTypes: number; // e.g. 5 +} +``` + +### Entity and Link Storage + +The interfaces below are **conceptual**. The implementation MUST use columnar typed arrays for 3M-scale data: + +```ts +// --- Conceptual interfaces --- +interface StoredEntity { + idx: EntityIdx; + entityId: EntityId; + typeSetKey: TypeSetKey; + label?: string; +} + +interface StoredLink { + idx: LinkIdx; + linkEntityId: EntityId; + leftEntityId: EntityId; + rightEntityId: EntityId; + leftIdx?: EntityIdx; // -1 if endpoint not yet loaded + rightIdx?: EntityIdx; // -1 if endpoint not yet loaded + linkTypeKey: TypeSetKey; +} + +// --- Columnar implementation --- +// Intern all strings once: +// entityIdInterner: string[] + Map +// typeSetInterner: string[] + Map +// +// Entity columns: +// entityTypeGroupIdx: Uint32Array (index into typeSetInterner) +// entityLabelIdx: Int32Array (index into label interner, -1 = no label) +// entityIdIdx: Uint32Array (index into entityIdInterner) +// +// Link columns: +// linkLeftIdx: Int32Array (-1 = unresolved) +// linkRightIdx: Int32Array (-1 = unresolved) +// linkTypeIdx: Uint32Array (index into typeSetInterner) +// linkEntityIdIdx: Uint32Array (index into entityIdInterner) +// +// Adjacency (CSR format for individual edge enumeration): +// entityIncidentLinkOffsets: Uint32Array (size = entityCount + 1) +// entityIncidentLinkIdxs: Uint32Array (size = total incident links) +``` + +The adjacency CSR is required for Section 6 (individual edge rendering) and Section 2 (community detection). It is rebuilt incrementally as links resolve. + +### Type System + +```ts +interface BitSet { + words: Uint32Array; + cardinality: number; +} + +interface TypeInfo { + idx: TypeIdx; + url: VersionedUrl; + title: string; + icon?: string; + parentIdxs: TypeIdx[]; // direct allOf parents + ancestorClosure: BitSet; // includes self and all allOf ancestors + depth: number; // longest path to a root type + rootIdxs: TypeIdx[]; // root ancestors (no parents) +} +``` + +### Type-Set Groups + +```ts +interface TypeSetGroup { + key: TypeSetKey; + directTypeIdxs: TypeIdx[]; + closure: BitSet; // union of all direct types' ancestor closures + + count: number; + entityIds: ChunkedIntList; + + // Deterministic latent cluster id. Exists even while merged. + standaloneClusterId: ClusterId; // "cluster:type:" + + // Current visible atomic cluster assignment. + // If small, may point to another type-set cluster or an "other" cluster. + assignedClusterId: ClusterId; + + isStandalone: boolean; + version: number; +} +``` + +### Cluster Tree + +```ts +interface ClusterLabel { + text: string; + primaryTypeIdx?: TypeIdx; + coverage: number; // fraction of entities matching the primary type + isMixed: boolean; // true if coverage < 0.65 +} + +interface CircleLayout { + x: number; + y: number; + r: number; + targetX: number; + targetY: number; + targetR: number; +} + +interface ClusterNode { + id: ClusterId; + kind: ClusterKind; + + parentId?: ClusterId; + childIds: ClusterId[]; + + // Which type-set groups are assigned to this atomic cluster. + groupKeys: TypeSetKey[]; + + count: number; + + // For labeling. directTypeMass counts asserted types, closureTypeMass + // includes inherited ancestors. + directTypeMass: Map; + closureTypeMass: Map; + + label: ClusterLabel; + layout: CircleLayout; + version: number; + + subclusters?: { + status: "none" | "queued" | "running" | "ready" | "failed"; + version: number; + childClusterIds: ClusterId[]; + }; +} +``` + +### Edge Aggregation + +```ts +interface EdgeTypeAgg { + linkTypeKey: TypeSetKey; + count: number; + sampleLinkIds: EntityId[]; +} + +interface EdgeAgg { + sourceId: string; // TypeSetKey (group level) or ClusterId (cluster level) + targetId: string; + totalCount: number; + byType: Map; +} +``` + +### Identity Sets + +Node entities and link entities are tracked in separate identity sets. This is critical for frontier detection (Section 7), which relies on `seenNodeEntityIds` to determine whether a link endpoint is loaded. + +```ts +// NEVER mix node and link entity IDs in the same set. +seenNodeEntityIds: Set; // loaded non-link entities +seenLinkEntityIds: Set; // loaded link entities + +entityIdToIdx: Map; +linkEntityIdToIdx: Map; +``` + +### Worker State + +```ts +interface WorkerState { + config: VizConfig; + mode: VizMode; // current scale-adaptive mode + + typeRegistry: TypeInfo[]; + typeInterner: Interner; + + seenNodeEntityIds: Set; + seenLinkEntityIds: Set; + entityIdToIdx: Map; + linkEntityIdToIdx: Map; + entities: StoredEntity[]; // conceptual; columnar in implementation + + typeSetGroups: Map; + clusters: Map; + + links: StoredLink[]; // conceptual; columnar in implementation + + // Base edge aggregates between type-set groups. + // These don't change when groups are visually merged/split. + // Sufficient for cuts COARSER than atomic clusters. + groupEdgeAgg: Map; + incidentGroupAggKeys: Map>; + + // Current macro aggregates between assigned atomic clusters. + // Lazy cache; cleared when assignments change. + clusterEdgeAgg: Map; + + // For cuts FINER than atomic clusters (community subclusters, + // entity-mode), use the CSR adjacency index to classify per-link. + // Group-level aggregates cannot be refined. + + // Links whose endpoint entity hasn't been loaded yet. + pendingLinksByEndpointId: Map; + + frontier: FrontierState; + + layoutVersion: number; + edgeVersion: number; + + communityCache: Map; + communityJobQueue: CommunityJob[]; + + microLayoutCache: Map; + viewport: ViewportState; + + // Seed positions for entities that resolve from frontier nodes. + initialEntityPositions: Map; +} +``` + +### Efficient Chunked Storage + +Avoids repeated copying of huge arrays: + +```ts +class ChunkedIntList { + private readonly chunkSize = 4096; + private chunks: Uint32Array[] = []; + private current: Uint32Array = new Uint32Array(this.chunkSize); + private offset = 0; + length = 0; + + push(value: number): void { + if (this.offset === this.chunkSize) { + this.chunks.push(this.current); + this.current = new Uint32Array(this.chunkSize); + this.offset = 0; + } + this.current[this.offset++] = value; + this.length++; + } + + *values(): Iterable { + for (const chunk of this.chunks) { + for (const value of chunk) yield value; + } + for (let i = 0; i < this.offset; i++) { + yield this.current[i]!; + } + } +} +``` + +--- + +## Section 1: Type-Set Clustering Algorithm + +### 1.1 Canonical Type Sets + +Two representations per entity: + +- **Direct type set**: sorted `metadata.entityTypeIds`. This is the exact grouping key. +- **Closure type set**: union of each direct type's `allOf` ancestors. Used for similarity and fallback labels. + +This avoids forcing the type DAG into a tree while still letting `[Employee]` and `[Person, Employee]` compare as similar. + +```ts +function makeTypeSetKey(typeIdxs: TypeIdx[]): TypeSetKey { + return [...new Set(typeIdxs)].sort((a, b) => a - b).join(","); +} + +function closureForDirectTypes( + directTypeIdxs: TypeIdx[], + typeRegistry: TypeInfo[], +): BitSet { + let result = emptyBitSet(typeRegistry.length); + for (const typeIdx of directTypeIdxs) { + result = bitSetOr(result, typeRegistry[typeIdx]!.ancestorClosure); + } + return result; +} + +function jaccard(a: BitSet, b: BitSet): number { + const intersection = bitSetIntersectionCount(a, b); + const union = a.cardinality + b.cardinality - intersection; + return union === 0 ? 1 : intersection / union; +} + +// Uses direct type sets, not closure. Prevents a generic [Person] +// from counting as a subset of every subclass closure. +function isDirectSubset(a: TypeIdx[], b: TypeIdx[]): boolean { + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + i++; + j++; + } else if (a[i]! > b[j]!) { + j++; + } else { + return false; + } + } + return i === a.length; +} +``` + +### 1.2 Type Registry Initialization + +Before any clustering can happen, the type registry must be populated from entity type schemas. Type schemas are available from the `closedMultiEntityTypes` and `definitions` fields of the GraphQL response. + +```ts +function initializeTypeRegistry( + entityTypeSchemas: Map, + state: WorkerState, +): void { + // Phase 1: intern all type URLs and create TypeInfo stubs. + for (const [url, schema] of entityTypeSchemas) { + const idx = state.typeInterner.getOrCreate(url); + if (state.typeRegistry[idx]) continue; // already initialized + + state.typeRegistry[idx] = { + idx, + url, + title: schema.title, + icon: schema.icon, + parentIdxs: [], + ancestorClosure: emptyBitSet(0), // computed in phase 2 + depth: 0, + rootIdxs: [], + }; + } + + // Phase 2: resolve allOf parents and compute ancestor closures. + for (const [url, schema] of entityTypeSchemas) { + const idx = state.typeInterner.getOrCreate(url); + const info = state.typeRegistry[idx]!; + + info.parentIdxs = (schema.allOf ?? []) + .map((ref) => state.typeInterner.get(ref.$ref)) + .filter((i): i is TypeIdx => i !== undefined); + } + + // Phase 3: compute transitive closures via BFS/DFS. + // Detect cycles (fail gracefully: treat as root). + for (const info of state.typeRegistry) { + if (!info) continue; + info.ancestorClosure = computeAncestorClosure(info.idx, state.typeRegistry); + info.depth = computeMaxDepth(info.idx, state.typeRegistry); + info.rootIdxs = findRootAncestors(info.idx, state.typeRegistry); + } +} + +// If a type is referenced by an entity but not in the schema map, +// create a stub TypeInfo with no parents/closure. This allows +// clustering to proceed with degraded labeling. +``` + +### 1.3 Initial Ingestion + +Link entities are treated as edges, not clustered as nodes. + +**Deterministic rule**: ingest all node entities first, then all link entities. This ensures every link endpoint that exists in the batch is already indexed when the link is processed. + +```ts +function ingestInitialEntities( + entities: EntityForGraph[], + state: WorkerState, +): void { + // Phase 1: node entities first. + for (const entity of entities) { + if (entity.linkData) continue; + ingestNodeEntity(entity, state); + } + + // Phase 2: link entities second. + for (const entity of entities) { + if (!entity.linkData) continue; + ingestLinkEntity(entity, state); + } + + classifyAndMergeAllTypeSetGroups(state); + materializeAllAtomicClusters(state); // materialize ALL, then label + computeAllClusterLabels(state); // IDF computed once over full set + buildDisplayHierarchy(state); + recomputeClusterEdgeAggregates(state); + computeStableCirclePacking(state); +} + +function ingestNodeEntity( + entity: EntityForGraph, + state: WorkerState, +): EntityIdx { + const entityId = entity.metadata.recordId.entityId; + + // Dedupe against node entity set (NOT link entity set). + if (state.seenNodeEntityIds.has(entityId)) { + return state.entityIdToIdx.get(entityId)!; + } + + // Dedupe direct type IDs before processing. + const directTypeIdxs = [ + ...new Set( + entity.metadata.entityTypeIds.map((url) => + state.typeInterner.getOrCreate(url), + ), + ), + ].sort((a, b) => a - b); + const key = makeTypeSetKey(directTypeIdxs); + + let group = state.typeSetGroups.get(key); + if (!group) { + group = { + key, + directTypeIdxs, + closure: closureForDirectTypes(directTypeIdxs, state.typeRegistry), + count: 0, + entityIds: new ChunkedIntList(), + standaloneClusterId: `cluster:type:${key}`, + assignedClusterId: `cluster:type:${key}`, + isStandalone: false, + version: 0, + }; + state.typeSetGroups.set(key, group); + } + + const idx = state.entities.length; + + // Atomically: allocate idx, add to identity sets, resolve pending links. + state.entityIdToIdx.set(entityId, idx); + state.seenNodeEntityIds.add(entityId); + state.entities.push({ + idx, + entityId, + typeSetKey: key, + label: deriveEntityLabel(entity, state), + }); + + group.count++; + group.version++; + group.entityIds.push(idx); + + // Resolve any links that were waiting for this entity. + resolvePendingLinksForEndpoint(state, entityId, idx); + + return idx; +} +``` + +### 1.3 Merging Small Type-Set Groups + +Small groups merge only into **anchors** (large groups), never into other small groups. This prevents order-dependent chaining. + +```ts +function classifyAndMergeAllTypeSetGroups(state: WorkerState): void { + const anchors: TypeSetGroup[] = []; + const small: TypeSetGroup[] = []; + + for (const group of state.typeSetGroups.values()) { + if (group.count >= state.config.minStandaloneTypeSet) { + anchors.push(group); + } else { + small.push(group); + } + } + + // During early pagination there may be no anchors. Promote the largest + // provisional groups to avoid a single useless root bubble. + if (anchors.length === 0 && small.length > 0) { + small + .toSorted((a, b) => b.count - a.count || a.key.localeCompare(b.key)) + .slice(0, Math.min(8, small.length)) + .forEach((group) => anchors.push(group)); + } + + const anchorIndex = buildAnchorInvertedIndex(anchors, state); + + for (const anchor of anchors) { + anchor.isStandalone = true; + anchor.assignedClusterId = anchor.standaloneClusterId; + } + + for (const group of small) { + if (anchors.includes(group)) continue; + group.isStandalone = false; + group.assignedClusterId = findMergeTarget( + group, + anchors, + anchorIndex, + state, + ); + } +} + +function findMergeTarget( + small: TypeSetGroup, + anchors: TypeSetGroup[], + anchorIndex: Map, + state: WorkerState, +): ClusterId { + // Candidate generation through shared closure types. + const candidates = new Map(); + for (const typeIdx of bitSetMembers(small.closure)) { + for (const anchor of anchorIndex.get(typeIdx) ?? []) { + candidates.set(anchor.key, anchor); + } + } + + let best: + | { + group: TypeSetGroup; + rawJaccard: number; + directSuperset: boolean; + adjustedScore: number; + } + | undefined; + + for (const anchor of candidates.values()) { + const rawJaccard = jaccard(small.closure, anchor.closure); + const directSuperset = isDirectSubset( + small.directTypeIdxs, + anchor.directTypeIdxs, + ); + const adjustedScore = rawJaccard + (directSuperset ? 0.1 : 0); + + if ( + !best || + adjustedScore > best.adjustedScore || + (adjustedScore === best.adjustedScore && anchor.key < best.group.key) + ) { + best = { group: anchor, rawJaccard, directSuperset, adjustedScore }; + } + } + + if ( + best && + (best.rawJaccard >= state.config.mergeJaccardMin || + (best.directSuperset && + best.rawJaccard >= state.config.mergeSubsetJaccardMin)) + ) { + return best.group.standaloneClusterId; + } + + // No good anchor match. Use a deterministic "Other" bucket keyed by the + // group's most informative root type. + return otherClusterIdForGroup(small, state); +} + +function buildAnchorInvertedIndex( + anchors: TypeSetGroup[], + state: WorkerState, +): Map { + const index = new Map(); + for (const anchor of anchors) { + for (const typeIdx of bitSetMembers(anchor.closure)) { + let list = index.get(typeIdx); + if (!list) { + list = []; + index.set(typeIdx, list); + } + list.push(anchor); + } + } + return index; +} + +function otherClusterIdForGroup( + group: TypeSetGroup, + state: WorkerState, +): ClusterId { + const primary = mostSpecificRootForGroup(group, state); + return `cluster:other:${primary ?? "unknown"}`; +} +``` + +### 1.4 Materializing Atomic Clusters + +After assignment, materialize one atomic cluster per standalone anchor and one per "other" bucket. **Important**: materialize ALL clusters first, THEN compute labels. This ensures IDF is computed over the complete set, not a partial iteration-order-dependent subset. + +```ts +function materializeAllAtomicClusters(state: WorkerState): void { + state.clusters.clear(); + + const root: ClusterNode = makeEmptyCluster("cluster:root", "root"); + state.clusters.set(root.id, root); + + const groupsByCluster = new Map(); + for (const group of state.typeSetGroups.values()) { + let list = groupsByCluster.get(group.assignedClusterId); + if (!list) { + list = []; + groupsByCluster.set(group.assignedClusterId, list); + } + list.push(group); + } + + for (const [clusterId, groups] of groupsByCluster) { + const kind: ClusterKind = clusterId.startsWith("cluster:other:") + ? "other" + : "type-set"; + const cluster = makeEmptyCluster(clusterId, kind); + cluster.groupKeys = groups.map((g) => g.key); + + for (const group of groups) { + cluster.count += group.count; + addTypeMass(cluster.directTypeMass, group.directTypeIdxs, group.count); + addClosureMass(cluster.closureTypeMass, group.closure, group.count); + } + + // Labels are NOT computed here. See computeAllClusterLabels. + state.clusters.set(cluster.id, cluster); + } +} + +function computeAllClusterLabels(state: WorkerState): void { + // Use ONLY atomic cluster count for IDF, not total clusters + // (which may include root/family/community nodes). + const atomicClusters = [...state.clusters.values()].filter( + (c) => c.kind === "type-set" || c.kind === "other", + ); + + const directDf = computeDocumentFrequency( + atomicClusters, + "directTypeMass", + 0.1, + ); + const closureDf = computeDocumentFrequency( + atomicClusters, + "closureTypeMass", + 0.1, + ); + + for (const cluster of atomicClusters) { + cluster.label = computeDistinctiveTypeLabel( + cluster, + directDf, + closureDf, + atomicClusters.length, + state, + ); + } +} +``` + +### 1.5 Distinctive Type Labeling + +Uses TF-IDF: a type's score is its coverage of the cluster weighted by how rare it is across all clusters. Prevents a merged cluster with 98% Person and 2% RareSubtype from being mislabeled. + +```ts +function computeDistinctiveTypeLabel( + cluster: ClusterNode, + state: WorkerState, +): ClusterLabel { + const allAtomicClusters = [...state.clusters.values()].filter( + (c) => c.kind === "type-set" || c.kind === "other", + ); + + const directDf = computeDocumentFrequency( + allAtomicClusters, + "directTypeMass", + 0.1, + ); + const closureDf = computeDocumentFrequency( + allAtomicClusters, + "closureTypeMass", + 0.1, + ); + + // Try direct types first, fall back to inherited closure types. + const directCandidate = bestTypeCandidate({ + cluster, + mass: cluster.directTypeMass, + df: directDf, + minCoverage: cluster.kind === "other" ? 0.35 : 0.5, + state, + }); + + const candidate = + directCandidate ?? + bestTypeCandidate({ + cluster, + mass: cluster.closureTypeMass, + df: closureDf, + minCoverage: 0.5, + state, + }); + + if (candidate) { + const type = state.typeRegistry[candidate.typeIdx]!; + return { + text: `${candidate.coverage < 0.65 ? "Mostly " : ""}${type.title}`, + primaryTypeIdx: candidate.typeIdx, + coverage: candidate.coverage, + isMixed: candidate.coverage < 0.65, + }; + } + + return { text: "Mixed entities", coverage: 0, isMixed: true }; +} + +function bestTypeCandidate(args: { + cluster: ClusterNode; + mass: Map; + df: Map; + minCoverage: number; + state: WorkerState; +}): { typeIdx: TypeIdx; coverage: number; score: number } | undefined { + let best: { typeIdx: TypeIdx; coverage: number; score: number } | undefined; + + for (const [typeIdx, count] of args.mass) { + const coverage = count / args.cluster.count; + if (coverage < args.minCoverage) continue; + + const type = args.state.typeRegistry[typeIdx]!; + const df = args.df.get(typeIdx) ?? 1; + const idf = Math.log((args.state.clusters.size + 1) / (df + 1)); + // Prefer deeper (more specific) types as tiebreaker. + const score = coverage * (idf + 0.05 * type.depth); + + if ( + !best || + score > best.score || + (score === best.score && + type.depth > args.state.typeRegistry[best.typeIdx]!.depth) + ) { + best = { typeIdx, coverage, score }; + } + } + + return best; +} +``` + +### 1.6 Building the Display Hierarchy + +The type-set partition is flat. The hierarchy is a **display roll-up**: bucket atomic clusters by primary root type, then build a bounded-fanout tree. + +**Invariant**: every internal node (including root) has at most `maxChildrenPerParent` children. + +**Important**: full HAC (agglomerative clustering) is O(n²) and infeasible for large buckets in a web worker. Use bounded methods instead: nearest-neighbor chain with strict caps, or deterministic feature bucketing, or top-K large children plus "Other" rollups. + +```ts +function buildDisplayHierarchy(state: WorkerState): void { + const root = state.clusters.get("cluster:root")!; + root.childIds = []; + + const atomicClusters = [...state.clusters.values()].filter( + (c) => c.kind === "type-set" || c.kind === "other", + ); + + const buckets = bucketByPrimaryRoot(atomicClusters, state); + + // Collect all top-level children, then enforce global fanout bound. + const topLevelChildren: ClusterNode[] = []; + + for (const [rootTypeIdx, clusters] of buckets) { + if (clusters.length === 1) { + topLevelChildren.push(clusters[0]!); + continue; + } + + // Create a family roll-up for each root-type bucket. + const familyId = `cluster:family:${rootTypeIdx}`; + const family = makeRollupCluster(familyId, "family", clusters, state); + state.clusters.set(family.id, family); + topLevelChildren.push(family); + + buildBoundedRollupSubtree(family, clusters, state); + } + + // Enforce maxChildrenPerParent at root level too. + if (topLevelChildren.length <= state.config.maxChildrenPerParent) { + for (const child of stableSortClusters(topLevelChildren)) { + child.parentId = root.id; + root.childIds.push(child.id); + } + } else { + buildBoundedRollupSubtree(root, topLevelChildren, state); + } + + root.count = sum(root.childIds.map((id) => state.clusters.get(id)!.count)); + root.label = { text: "All entities", coverage: 1, isMixed: true }; +} + +function buildBoundedRollupSubtree( + parent: ClusterNode, + children: ClusterNode[], + state: WorkerState, +): void { + if (children.length <= state.config.maxChildrenPerParent) { + parent.childIds = stableSortClusters(children).map((child) => { + child.parentId = parent.id; + return child.id; + }); + return; + } + + // Bounded hierarchy construction: + // 1. Sort children by count descending. + // 2. Take top (maxChildrenPerParent - 1) as direct children. + // 3. Remaining go into an "Other" rollup. + // This is O(n log n) and deterministic. + // For more semantic grouping, use MinHash/LSH candidate generation + // with nearest-neighbor chain HAC, capped at O(n * maxChildrenPerParent). + const sorted = stableSortClusters(children); + const maxDirect = state.config.maxChildrenPerParent - 1; + const direct = sorted.slice(0, maxDirect); + const overflow = sorted.slice(maxDirect); + + parent.childIds = []; + for (const child of direct) { + child.parentId = parent.id; + parent.childIds.push(child.id); + } + + if (overflow.length > 0) { + const otherId = `cluster:family:overflow:${stableHash(parent.id)}`; + const other = makeRollupCluster(otherId, "family", overflow, state); + other.parentId = parent.id; + parent.childIds.push(other.id); + state.clusters.set(other.id, other); + + // Recurse if overflow itself exceeds the bound. + if (overflow.length > state.config.maxChildrenPerParent) { + buildBoundedRollupSubtree(other, overflow, state); + } else { + for (const child of overflow) { + child.parentId = other.id; + } + other.childIds = overflow.map((c) => c.id); + } + } +} + +// Weighted Jaccard over type-mass profiles (not binary sets). +function weightedJaccardMass( + a: Map, + b: Map, +): number { + let numerator = 0; + let denominator = 0; + const keys = new Set([...a.keys(), ...b.keys()]); + for (const key of keys) { + const av = a.get(key) ?? 0; + const bv = b.get(key) ?? 0; + numerator += Math.min(av, bv); + denominator += Math.max(av, bv); + } + return denominator === 0 ? 1 : numerator / denominator; +} +``` + +--- + +## Section 2: Recursive Sub-Clustering via Link Structure + +### 2.1 Algorithm Choice + +For browser web workers: + +1. **Connected components** first (trivial, O(V+E)). +2. **Bounded label propagation** inside large components (near-linear, simple, stable). +3. Optional idle-time refinement with Louvain for medium clusters. + +Label propagation is the right interactive default. Louvain/Leiden can be an optional background refinement. + +### 2.2 When Sub-Clustering Runs + +**Lazily**, when a cluster is about to open and is too large to show entities directly. + +```ts +function ensureSubclusters(clusterId: ClusterId, state: WorkerState): void { + const cluster = state.clusters.get(clusterId)!; + if (cluster.count <= state.config.entityRevealMax) return; + + const cacheKey = communityCacheKey(cluster, state); + if (state.communityCache.has(cacheKey)) { + attachCachedSubclusters( + cluster, + state.communityCache.get(cacheKey)!, + state, + ); + return; + } + + if ( + cluster.subclusters?.status === "queued" || + cluster.subclusters?.status === "running" + ) { + return; + } + + cluster.subclusters = { + status: "queued", + version: cluster.version, + childClusterIds: [], + }; + state.communityJobQueue.push({ + clusterId, + cacheKey, + priority: screenRadiusPx(cluster, state.viewport), + }); +} +``` + +### 2.3 Community Detection + +Build a local induced graph (CSR format) for the cluster from loaded edges. + +```ts +interface CsrGraph { + nodeIds: EntityIdx[]; + offsets: Int32Array; + neighbors: Int32Array; + weights: Float32Array; +} + +function subclusterByLinks( + cluster: ClusterNode, + state: WorkerState, +): ClusterNode[] { + const entityIds = collectEntitiesForCluster(cluster, state); + + if (entityIds.length <= state.config.entityRevealMax) return []; + + // Very large clusters: coarse link-signature bucketing first. + if (entityIds.length > state.config.communityWorkerNodeCap) { + return coarseLinkSignatureBuckets(cluster, entityIds, state); + } + + const csr = buildInducedCsr(entityIds, state); + const components = connectedComponents(csr); + const communities: EntityIdx[][] = []; + + for (const component of components) { + if (component.length <= state.config.communityMaxSize) { + communities.push(component.map((localIdx) => csr.nodeIds[localIdx]!)); + continue; + } + const labels = boundedLabelPropagation(csr, component, state.config); + communities.push(...labelsToCommunities(labels, csr)); + } + + const normalized = normalizeCommunitySizes(communities, state.config); + return materializeCommunityClusters(cluster, normalized, state); +} +``` + +### 2.4 Bounded Label Propagation + +```ts +function boundedLabelPropagation( + graph: CsrGraph, + component: number[], + config: VizConfig, +): Int32Array { + const n = graph.nodeIds.length; + const labels = new Int32Array(n); + const sizes = new Int32Array(n); + + for (const localIdx of component) { + labels[localIdx] = localIdx; + sizes[localIdx] = 1; + } + + const maxIterations = 20; + const alpha = 0.35; // size penalty + const stabilityBias = 0.01; // prefer keeping current label + + for (let iteration = 0; iteration < maxIterations; iteration++) { + let changed = 0; + const order = deterministicShuffle(component, iteration); + + for (const v of order) { + const current = labels[v]!; + const scores = new Map(); + + for (let e = graph.offsets[v]!; e < graph.offsets[v + 1]!; e++) { + const u = graph.neighbors[e]!; + const label = labels[u]!; + const weight = graph.weights[e]!; + scores.set(label, (scores.get(label) ?? 0) + weight); + } + + scores.set(current, (scores.get(current) ?? 0) + stabilityBias); + + let bestLabel = current; + let bestScore = -Infinity; + + for (const [label, rawScore] of scores) { + const sizePenalty = Math.pow(Math.max(1, sizes[label]!), alpha); + const score = rawScore / sizePenalty; + if (score > bestScore || (score === bestScore && label < bestLabel)) { + bestScore = score; + bestLabel = label; + } + } + + if (bestLabel !== current) { + sizes[current]!--; + sizes[bestLabel]!++; + labels[v] = bestLabel; + changed++; + } + } + + if (changed / component.length < 0.005) break; + } + + return labels; +} +``` + +### 2.5 Very Large Clusters (>50k entities) + +Use a coarse first pass based on link signatures rather than full community detection: + +```ts +function coarseLinkSignatureBuckets( + cluster: ClusterNode, + entityIds: EntityIdx[], + state: WorkerState, +): ClusterNode[] { + const buckets = new Map(); + + for (const entityIdx of entityIds) { + // Signature: top outgoing link types, top incoming link types, + // neighbor macro-cluster labels, presence of high-degree hubs. + const signature = linkSignature(entityIdx, state); + const key = signatureToBucketKey( + signature, + state.config.maxChildrenPerParent, + ); + + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + } + bucket.push(entityIdx); + } + + return materializeCommunityClusters(cluster, [...buckets.values()], state); +} +``` + +### 2.6 Community Labels + +Communities have no inherent names. Label using overrepresented features scored with TF-IDF across sibling communities: + +```ts +type CommunityFeature = + | { kind: "internal-link-type"; linkTypeKey: TypeSetKey } + | { + kind: "external-neighbor-type"; + direction: "in" | "out"; + linkTypeKey: TypeSetKey; + neighborClusterLabel: string; + } + | { kind: "hub"; entityIdx: EntityIdx; label: string }; + +function labelCommunity( + community: EntityIdx[], + siblingCommunities: EntityIdx[][], + state: WorkerState, +): ClusterLabel { + const featureMass = collectCommunityFeatures(community, state); + const df = computeCommunityFeatureDocumentFrequency( + siblingCommunities, + state, + ); + + let best: + | { feature: CommunityFeature; coverage: number; score: number } + | undefined; + + for (const [featureKey, count] of featureMass) { + const feature = decodeFeature(featureKey); + const coverage = count / community.length; + const idf = Math.log( + (siblingCommunities.length + 1) / ((df.get(featureKey) ?? 1) + 1), + ); + const score = coverage * idf; + if (!best || score > best.score) { + best = { feature, coverage, score }; + } + } + + if (best && best.coverage >= 0.25) { + return { + text: featureToLabel(best.feature, state), + coverage: best.coverage, + isMixed: best.coverage < 0.5, + }; + } + + // Fallback: name after the highest-degree internal hub. + const hub = topInternalDegreeHub(community, state); + if (hub) { + return { text: `Around ${hub.label}`, coverage: 0, isMixed: true }; + } + + return { text: "Community", coverage: 0, isMixed: true }; +} +``` + +### 2.7 Embedding-Based Subdivision + +When a cluster is too large for direct entity display and link-based community detection produces insufficient granularity (e.g., 400k entities of the same type with sparse internal links), fall back to **embedding-based recursive k-means subdivision**. + +This is the third clustering axis: + +1. Type-set clustering: _what kind_ of entity +2. Community detection via links: _how they're connected_ +3. Embedding k-means: _what they're semantically about_ + +#### 2.7.1 Trigger + +Embedding subdivision runs lazily when: + +- A cluster is about to open (screen radius crosses threshold). +- Its count exceeds `entityRevealMax`. +- Community detection either was not attempted (no links) or did not produce enough children. + +```ts +function needsEmbeddingSubdivision( + cluster: ClusterNode, + config: VizConfig, +): boolean { + if (cluster.count <= config.entityRevealMax) return false; + // Community detection already produced usable children. + if (cluster.childIds.length >= 2) return false; + return true; +} +``` + +#### 2.7.2 Embedding Fetch + +**Critical**: do NOT fetch raw 1536-D embeddings for large clusters. For 50k entities at 1536×Float32, that is ~300 MB. + +The server stores two representations: + +- Original 1536-D embedding (for vec2slug, search, etc.) +- Reduced 128-D projected embedding (for clustering) + +The worker fetches the 128-D projections for clustering. For 50k entities at 128×Float32, that is ~25 MB, manageable in a single fetch. + +For clusters exceeding `embeddingClientNodeCap` (e.g., 25k), prefer server-side clustering that returns child memberships, centroids, and counts. + +```ts +interface EmbeddingFetchRequest { + readonly type: "FETCH_EMBEDDINGS"; + readonly clusterId: ClusterId; + readonly entityIds: readonly EntityId[]; +} + +interface EmbeddingFetchResponse { + readonly type: "EMBEDDINGS_READY"; + readonly clusterId: ClusterId; + // Column-major: Float32Array of length entityIds.length * projectionDims + readonly projectedEmbeddings: Float32Array; + readonly dims: number; +} +``` + +#### 2.7.3 Spherical K-Means + +Use **spherical k-means** on L2-normalized embeddings with **k-means++ initialization**. + +Adaptive k selection: + +```ts +function chooseEmbeddingK(n: number, config: VizConfig): number { + if (n <= config.entityRevealMax) return 0; + const targetLeafSize = Math.floor( + config.entityRevealMax * config.embeddingTargetLeafFillRatio, + ); + return Math.max( + 2, + Math.min(config.embeddingMaxK, Math.ceil(n / targetLeafSize)), + ); +} +``` + +With `entityRevealMax = 500` and `embeddingTargetLeafFillRatio = 0.75`: + +- `n = 600` → `k = 2` +- `n = 4_000` → `k = 11` +- `n = 400_000` → `k = 32` (capped by `embeddingMaxK`) + +Algorithm: + +1. L2-normalize all embedding vectors. +2. Initialize k centroids with k-means++ (or k-means|| for very large n). +3. Run Lloyd iterations using cosine similarity (dot product on normalized vectors). +4. After centroid update, re-normalize centroids. +5. Converge when <0.1% of assignments change, or after 50 iterations. +6. Final full assignment pass to compute exact memberships. + +```ts +function sphericalKMeans( + embeddings: Float32Array, // n * dims, row-major, L2-normalized + n: number, + dims: number, + k: number, + maxIterations: number, +): { assignments: Int32Array; centroids: Float32Array } { + const centroids = kmeansppInit(embeddings, n, dims, k); + const assignments = new Int32Array(n); + + for (let iter = 0; iter < maxIterations; iter++) { + let changed = 0; + + // Assign each point to nearest centroid by dot product. + for (let idx = 0; idx < n; idx++) { + const offset = idx * dims; + let bestC = 0; + let bestSim = -Infinity; + + for (let c = 0; c < k; c++) { + let sim = 0; + const cOffset = c * dims; + for (let d = 0; d < dims; d++) { + sim += embeddings[offset + d]! * centroids[cOffset + d]!; + } + if (sim > bestSim) { + bestSim = sim; + bestC = c; + } + } + + if (assignments[idx] !== bestC) { + assignments[idx] = bestC; + changed++; + } + } + + if (changed / n < 0.001) break; + + // Recompute centroids and normalize. + centroids.fill(0); + const counts = new Int32Array(k); + for (let idx = 0; idx < n; idx++) { + const c = assignments[idx]!; + counts[c]++; + const cOffset = c * dims; + const offset = idx * dims; + for (let d = 0; d < dims; d++) { + centroids[cOffset + d]! += embeddings[offset + d]!; + } + } + + for (let c = 0; c < k; c++) { + const cOffset = c * dims; + let norm = 0; + for (let d = 0; d < dims; d++) { + norm += centroids[cOffset + d]! * centroids[cOffset + d]!; + } + norm = Math.sqrt(norm) || 1; + for (let d = 0; d < dims; d++) { + centroids[cOffset + d]! /= norm; + } + } + } + + return { assignments, centroids }; +} +``` + +#### 2.7.4 Size Invariant and Fallback + +K-means does not guarantee balanced clusters. Enforce: + +> Every entity-reveal leaf must contain at most `entityRevealMax` entities. + +After k-means: + +1. Merge tiny children below `communityMinSize` into nearest larger centroid. +2. Oversized children are marked expandable (recurse on next zoom). +3. If recursive splitting fails to make progress (one child repeatedly receives >90% of points), fall back to deterministic bounded partitioning: split by projection onto the first principal direction, or paginate as "Similarity group 1", "Similarity group 2", etc. + +Do NOT eagerly recurse all levels for a 400k cluster. Materialize one subdivision level at a time, lazily. + +#### 2.7.5 Cluster Labeling with vec2slug + +Generate human-readable labels from cluster centroid embeddings using vec2slug, a 11.5M-parameter ONNX transformer decoder (~44 MiB) that produces URL-style slugs from embeddings. + +Runs in a **separate web worker** via WASM (not the graph worker). The graph worker stays focused on clustering and layout. + +Flow: + +1. After k-means produces children, compute each child's centroid in the **original 1536-D embedding space** (not the 128-D projection; vec2slug was trained on 1536-D OpenAI embeddings). +2. Send centroids to the slug worker. +3. Slug worker generates slugs (~89ms each, serialized; ~3s for 32 clusters). +4. Labels arrive asynchronously and replace placeholders. + +Use the centroid only when the cluster is semantically tight. Track concentration: + +```ts +// rho = ||sum(v_i)|| / n, where v_i are L2-normalized embeddings. +// rho near 1 = tight cluster, rho near 0 = diffuse/multi-topic. +function centroidConcentration( + embeddings: Float32Array, + memberIdxs: Int32Array, + dims: number, +): number { + const sum = new Float64Array(dims); + for (const idx of memberIdxs) { + const offset = idx * dims; + for (let d = 0; d < dims; d++) { + sum[d] += embeddings[offset + d]!; + } + } + let norm = 0; + for (let d = 0; d < dims; d++) { + norm += sum[d]! * sum[d]!; + } + return Math.sqrt(norm) / memberIdxs.length; +} +``` + +Labeling strategy: + +- `rho >= embeddingMinConcentration`: use vec2slug on centroid. Label: slug converted to title case. +- `rho < embeddingMinConcentration`: use the medoid (entity closest to centroid). Label: "Around {medoid label}". +- If both fail: "Similar group {n}". + +Placeholder labels while slugs are pending: "Similar group 1", "Similar group 2", etc. + +#### 2.7.6 Subdivision State + +```ts +type EmbeddingSubdivisionState = + | "idle" + | "fetching-embeddings" + | "clustering" + | "children-ready-labels-pending" + | "ready" + | "error"; +``` + +Render children immediately when memberships are ready (`children-ready-labels-pending`). Do not block on slug generation. + +#### 2.7.7 Membership Store + +Embedding subdivisions create clusters without `groupKeys` (they split within a single type-set group). Use an explicit membership store: + +```ts +// Indexed by ClusterId → array of EntityIdx belonging to this cluster. +embeddingMembership: Map; +``` + +Membership lookup: + +- If cluster has `embeddingMembership` entry: use it. +- Else: collect from `groupKeys` as before. + +Tree invariant (must hold at all times): + +- Children are disjoint. +- `union(children.members) = parent.members` +- `sum(children.count) = parent.count` + +Publish a new child set atomically only after assignment is complete. Until then, keep rendering the parent. + +--- + +## Section 3: Incremental Update Algorithm + +### 3.1 Batch Ingestion + +**Deterministic rule**: node entities first, then link entities (same as initial ingestion). + +```ts +function ingestBatch(batch: EntityForGraph[], state: WorkerState): WorkerDiff { + const affectedGroupKeys = new Set(); + + // Phase 1: node entities first. + for (const item of batch) { + if (item.linkData) continue; + const id = item.metadata.recordId.entityId; + if (state.seenNodeEntityIds.has(id)) continue; + + const idx = ingestNodeEntity(item, state); + affectedGroupKeys.add(state.entities[idx]!.typeSetKey); + // Note: ingestNodeEntity already calls resolvePendingLinksForEndpoint. + } + + // Phase 2: link entities second. + for (const item of batch) { + if (!item.linkData) continue; + if (state.seenLinkEntityIds.has(item.metadata.recordId.entityId)) continue; + ingestLinkEntity(item, state); + } + + const changedAssignments = updateTypeSetAssignmentsIncrementally( + affectedGroupKeys, + state, + ); + updateDisplayHierarchyIncrementally(changedAssignments, state); + updateStableCirclePackingForDirtyParents(state); + invalidateAffectedCommunityCaches(changedAssignments, state); + + return buildWorkerDiff(state); +} +``` + +### 3.2 Incremental Assignment Updates + +Only two events matter for insert-only pagination: + +1. A new small group appears. +2. A small group crosses `minStandaloneTypeSet` and becomes standalone. + +**Critical**: track old vs new counts to compute correct deltas. + +```ts +interface GroupSnapshot { + key: TypeSetKey; + oldCount: number; + newCount: number; + oldAssignment: ClusterId; +} + +function updateTypeSetAssignmentsIncrementally( + affectedGroupKeys: Set, + state: WorkerState, +): AssignmentChange[] { + const changes: AssignmentChange[] = []; + + // Snapshot old state before any mutations. + const snapshots = new Map(); + for (const key of affectedGroupKeys) { + const group = state.typeSetGroups.get(key)!; + snapshots.set(key, { + key, + oldCount: group.count - 1, // ingestNodeEntity already incremented + newCount: group.count, + oldAssignment: group.assignedClusterId, + }); + } + + for (const key of affectedGroupKeys) { + const group = state.typeSetGroups.get(key)!; + const snap = snapshots.get(key)!; + + if ( + group.count >= state.config.minStandaloneTypeSet && + !group.isStandalone + ) { + // Crossed threshold: promote to standalone. + // The old cluster loses snap.oldCount entities, the new one gets snap.newCount. + changes.push(...promoteGroupToStandalone(group, snap, state)); + } else if (!group.isStandalone) { + // Still small: check if merge target changed. + const newClusterId = findMergeTargetIncremental(group, state); + if (newClusterId !== group.assignedClusterId) { + changes.push(changeGroupAssignment(group, snap, newClusterId, state)); + } else { + // Assignment unchanged: apply count delta to existing cluster. + const delta = snap.newCount - snap.oldCount; + incrementClusterCount(group.assignedClusterId, delta, group, state); + } + } else { + // Already standalone: apply count delta. + const delta = snap.newCount - snap.oldCount; + incrementClusterCount(group.assignedClusterId, delta, group, state); + } + } + + return changes; +} +``` + +### 3.3 Promotion: Small Group Becomes Standalone + +When a group crosses the threshold, it becomes a new anchor. Nearby small groups may want to re-target to it. + +```ts +function promoteGroupToStandalone( + group: TypeSetGroup, + state: WorkerState, +): AssignmentChange[] { + const changes: AssignmentChange[] = []; + + group.isStandalone = true; + changes.push(changeGroupAssignment(group, group.standaloneClusterId, state)); + + // New anchor may attract nearby small groups. + const candidateSmallGroups = smallGroupsSharingTypes(group, state); + for (const small of candidateSmallGroups) { + if (small.isStandalone || small.key === group.key) continue; + const newTarget = findMergeTargetIncremental(small, state); + if (newTarget !== small.assignedClusterId) { + changes.push(changeGroupAssignment(small, newTarget, state)); + } + } + + return changes; +} +``` + +### 3.4 Changing Group Assignment + +Updates cluster counts and edge aggregates through group-level aggregates, avoiding walks over individual entities. + +```ts +function changeGroupAssignment( + group: TypeSetGroup, + newClusterId: ClusterId, + state: WorkerState, +): AssignmentChange { + const oldClusterId = group.assignedClusterId; + if (oldClusterId === newClusterId) { + return { groupKey: group.key, oldClusterId, newClusterId }; + } + + decrementClusterMass(oldClusterId, group, state); + incrementClusterMass(newClusterId, group, state); + + // Update edge aggregates: remap group-level edges to new cluster assignment. + for (const edgeAggKey of state.incidentGroupAggKeys.get(group.key) ?? []) { + const groupAgg = state.groupEdgeAgg.get(edgeAggKey)!; + const resolve = (id: string) => + id === group.key + ? oldClusterId + : state.typeSetGroups.get(id)!.assignedClusterId; + const resolveNew = (id: string) => + id === group.key + ? newClusterId + : state.typeSetGroups.get(id)!.assignedClusterId; + + decrementClusterEdgeAgg( + resolve(groupAgg.sourceId), + resolve(groupAgg.targetId), + groupAgg, + state, + ); + incrementClusterEdgeAgg( + resolveNew(groupAgg.sourceId), + resolveNew(groupAgg.targetId), + groupAgg, + state, + ); + } + + group.assignedClusterId = newClusterId; + markLayoutDirty(oldClusterId, state); + markLayoutDirty(newClusterId, state); + + return { groupKey: group.key, oldClusterId, newClusterId }; +} +``` + +### 3.5 Visual Budding for Promoted Clusters + +When a merged group becomes standalone, it "buds" out of the old cluster: + +```ts +function initializePromotedClusterLayout( + newCluster: ClusterNode, + oldCluster: ClusterNode, +): void { + const angle = stableHashToAngle(newCluster.id); + newCluster.layout.x = + oldCluster.layout.x + Math.cos(angle) * oldCluster.layout.r * 0.25; + newCluster.layout.y = + oldCluster.layout.y + Math.sin(angle) * oldCluster.layout.r * 0.25; + newCluster.layout.r = 1; // starts tiny + + newCluster.layout.targetX = newCluster.layout.x; + newCluster.layout.targetY = newCluster.layout.y; + newCluster.layout.targetR = radiusForCount(newCluster.count); +} +``` + +### 3.6 Stable Circle Packing + +D3 circle packing is not truly incremental (full recompute shuffles nodes). Use a stable approach: + +- Keep previous positions. +- Repack only dirty sibling sets. +- Sort siblings by previous polar angle around their parent. +- Insert new clusters near the most similar sibling. +- Collision relaxation + weak springs to old positions. + +```ts +function stablePackChildren(parent: ClusterNode, state: WorkerState): void { + const children = parent.childIds.map((id) => state.clusters.get(id)!); + + for (const child of children) { + child.layout.targetR = radiusForCount(child.count); + + // New clusters that don't have a position yet: place near most similar sibling. + if (!Number.isFinite(child.layout.targetX)) { + const sibling = mostSimilarSibling(child, children, state); + const angle = stableHashToAngle(child.id); + child.layout.targetX = + sibling.layout.targetX + + Math.cos(angle) * (sibling.layout.targetR + child.layout.targetR + 8); + child.layout.targetY = + sibling.layout.targetY + + Math.sin(angle) * (sibling.layout.targetR + child.layout.targetR + 8); + } + } + + // Preserve angular order where possible. + children.sort((a, b) => { + const aa = Math.atan2( + a.layout.y - parent.layout.y, + a.layout.x - parent.layout.x, + ); + const bb = Math.atan2( + b.layout.y - parent.layout.y, + b.layout.x - parent.layout.x, + ); + return aa - bb || a.id.localeCompare(b.id); + }); + + for (let iter = 0; iter < 80; iter++) { + resolveCircleCollisions(children, 4); + applyWeakSpringsToPreviousPositions(children, 0.02); + applyWeakCentering(children, parent, 0.005); + } + + parent.layout.targetR = enclosingCircleRadius(children); +} +``` + +--- + +## Section 4: Semantic Zoom State Machine + +### 4.1 Per-Cluster Screen-Space Size + +Do **not** use a single global zoom level. With circle packing, clusters have different world radii. LOD decisions depend on each cluster's screen-space radius. + +```ts +function viewportScale(viewport: { zoom: number }): number { + return Math.pow(2, viewport.zoom); +} + +function screenRadiusPx( + cluster: ClusterNode, + viewport: { zoom: number }, +): number { + return cluster.layout.r * viewportScale(viewport); +} +``` + +### 4.2 Visible Cut Algorithm + +The visible cut is a set of nodes from the cluster tree. Descendants of a rendered cluster are not also rendered unless that cluster is "open." + +Uses **hysteresis** (different open/close thresholds) to prevent flickering. + +```ts +type LodMode = + | "cluster" // render as bubble + | "children" // show child clusters + | "entities-pending" // entities requested, not yet laid out + | "entities"; // individual entities visible + +interface LodItem { + clusterId: ClusterId; + mode: LodMode; +} + +function computeVisibleCut( + rootId: ClusterId, + state: WorkerState, + viewport: ViewportState, + previous: PreviousLodState, +): LodItem[] { + const result: LodItem[] = []; + const queue = new MaxPriorityQueue((cluster) => + screenRadiusPx(cluster, viewport), + ); + + queue.push(state.clusters.get(rootId)!); + + let renderedClusters = 0; + let renderedEntities = 0; + + while (!queue.isEmpty()) { + const node = queue.pop(); + if (!clusterIntersectsViewport(node, viewport)) continue; + + const rPx = screenRadiusPx(node, viewport); + + // Hysteresis: different thresholds for opening vs closing. + const wasOpen = previous.wasShowingChildren(node.id); + const openChildren = wasOpen + ? rPx >= state.config.closeChildrenRadiusPx + : rPx >= state.config.openChildrenRadiusPx; + + const wasShowingEntities = previous.wasShowingEntities(node.id); + const openEntities = wasShowingEntities + ? rPx >= state.config.closeEntitiesRadiusPx + : rPx >= state.config.openEntitiesRadiusPx; + + const hasChildren = node.childIds.length > 0; + + // Leaf cluster small enough to show entities? + if ( + !hasChildren && + node.count <= state.config.entityRevealMax && + openEntities && + renderedEntities + node.count <= state.config.maxRenderedEntities + ) { + const layoutReady = hasMicroLayoutReady(node.id, state); + result.push({ + clusterId: node.id, + mode: layoutReady ? "entities" : "entities-pending", + }); + renderedEntities += node.count; + continue; + } + + // Leaf cluster too large: request subclusters. + if ( + !hasChildren && + node.count > state.config.entityRevealMax && + openChildren + ) { + ensureSubclusters(node.id, state); + } + + // Has children and big enough to open? + if ( + hasChildren && + openChildren && + renderedClusters + node.childIds.length <= + state.config.maxRenderedClusters + ) { + // "children" mode is a container; it does NOT count as a rendered cluster. + // Only actual rendered drawables (cluster bubbles, entity sets) count. + result.push({ clusterId: node.id, mode: "children" }); + for (const childId of node.childIds) { + queue.push(state.clusters.get(childId)!); + } + // Do NOT increment renderedClusters here. Children will be counted + // when they are individually processed as "cluster" or "entities". + continue; + } + + // Default: render as a single bubble. + result.push({ clusterId: node.id, mode: "cluster" }); + renderedClusters++; + } + + return result; +} +``` + +### 4.3 Click-to-Zoom + +Clicking a cluster changes the viewport, not the LOD state directly. The LOD state machine then opens the cluster because its screen radius crosses the threshold. + +```ts +function viewportForClusterOpen( + cluster: ClusterNode, + desiredScreenRadiusPx: number, +): { target: [number, number]; zoom: number } { + return { + target: [cluster.layout.x, cluster.layout.y], + zoom: Math.log2(desiredScreenRadiusPx / cluster.layout.r), + }; +} +``` + +### 4.4 Transitions + +- **Cluster → children**: parent bubble remains as faint outline, children scale up from center, parent fill fades out. +- **Children → parent**: reverse animation. +- **Cluster → entities**: entity nodes fade/scale in at micro-layout positions, cluster becomes boundary halo, aggregated edges cross-fade to individual edges. +- **Aggregated → individual edges**: cross-fade, do not morph geometry. +- **Duration**: 150-250ms. Skip or shorten during rapid wheel zoom, low-power devices, or `prefers-reduced-motion`. + +--- + +## Section 5: Force Layout Scoping + +### 5.1 When Force Layout Runs + +A cluster gets force layout only when: + +```ts +function shouldRunForceLayout( + lodItem: LodItem, + cluster: ClusterNode, + viewport: ViewportState, + state: WorkerState, +): boolean { + return ( + (lodItem.mode === "entities" || lodItem.mode === "entities-pending") && + cluster.count <= state.config.forceMaxNodes && + clusterIntersectsViewport(cluster, viewport) + ); +} +``` + +If `cluster.count > forceMaxNodes`, do not run force layout; request subclusters instead. + +### 5.2 Cached Force State + +```ts +interface MicroLayoutCacheEntry { + clusterId: ClusterId; + clusterVersion: number; + edgeVersion: number; + positions: Float32Array; // [x0, y0, x1, y1, ...], local cluster coords + velocities: Float32Array; + alpha: number; + status: "running" | "paused" | "settled"; + lastUsedAt: number; +} +``` + +- **Pan away**: pause simulation, keep positions + velocities, evict only under LRU memory budget. +- **Pan back**: render cached positions immediately, resume with low alpha if graph changed. +- **New nodes arrive**: place near centroid of loaded neighbors (or deterministic phyllotaxis), run warm-up ticks. + +### 5.3 Worker Simulation Loop + +Positions are **local to the cluster**. World position is `cluster.layout.x + localX`, `cluster.layout.y + localY`. If the macro cluster moves, the local layout remains valid. + +```ts +function runMicroLayoutJob(job: MicroLayoutJob, state: WorkerState): void { + const cacheKey = microLayoutCacheKey(job.clusterId, state); + let layout = state.microLayoutCache.get(cacheKey); + + if (!layout) { + layout = initializeMicroLayout(job.clusterId, state); + state.microLayoutCache.set(cacheKey, layout); + } + + layout.status = "running"; + + while (layout.alpha > 0.02 && !job.cancelled) { + const start = performance.now(); + // Yield to message loop every ~8ms for responsiveness. + while (performance.now() - start < 8 && layout.alpha > 0.02) { + tickForceSimulation(layout, job.clusterId, state); + } + postMicroLayoutPartial(job.clusterId, layout.positions, layout.alpha); + } + + layout.status = job.cancelled ? "paused" : "settled"; +} +``` + +### 5.4 Boundary-Crossing Edges + +Do not let external edges pull nodes outside their cluster. Use **boundary anchors**. + +```ts +function boundaryAnchor( + source: ClusterNode, + target: ClusterNode, +): { x: number; y: number } { + const dx = target.layout.x - source.layout.x; + const dy = target.layout.y - source.layout.y; + const len = Math.hypot(dx, dy) || 1; + return { + x: (dx / len) * source.layout.r * 0.85, + y: (dy / len) * source.layout.r * 0.85, + }; +} +``` + +Internal edges: normal force springs. External edges: weak springs to boundary anchors, or ignored by physics and rendered as bundled edges. + +**Confinement force** keeps nodes inside their cluster: + +```ts +function applyConfinement(node: SimNode, radius: number): void { + const d = Math.hypot(node.x, node.y); + const maxD = radius - node.radius; + if (d > maxD) { + const excess = d - maxD; + node.vx -= (node.x / d) * excess * 0.05; + node.vy -= (node.y / d) * excess * 0.05; + } +} +``` + +--- + +## Section 6: Edge Aggregation and Bubble Ports + +The LOD cut induces an equivalence relation on entities. Each entity maps to exactly one visible cluster owner. The rendered edge set is the **quotient multigraph** of stored links under that owner map: + +- If two endpoint entities map to **different visible clusters**: render one aggregated edge between those clusters, counted by `linkTypeKey`. +- If they map to the **same visible cluster** and that cluster is in `mode: "entities"`: render the original stored link individually. +- If they map to the same visible cluster but the cluster is collapsed/pending: suppress the internal self-loop. + +Every stored link contributes to exactly one of `{ aggregate edge, individual edge, hidden internal edge }`. + +**Visual goal**: edges communicate _which_ clusters are connected, _how strongly_, and _via what link types_. Bubble ports achieve this by routing all edges between two clusters through dedicated boundary points, producing clean bundled paths instead of a tangle of crossing lines. + +### 6.1 Additional Types + +```ts +type PairKey = string; +type VisualEdgeKey = string; + +const SEP = "\u001f"; + +// Stable semantic keys (not array-index based, so transitions don't shimmer). +function makePairKey( + a: ClusterId, + b: ClusterId, + directed: boolean, +): { key: PairKey; sourceId: ClusterId; targetId: ClusterId } { + if (!directed && a > b) [a, b] = [b, a]; + return { key: `${a}${SEP}${b}`, sourceId: a, targetId: b }; +} + +function aggVisualKey( + pairKey: PairKey, + typeKey: TypeSetKey | "__collapsed__", +): VisualEdgeKey { + return `agg:${pairKey}:${typeKey}`; +} + +function individualVisualKey(linkIdx: number): VisualEdgeKey { + return `link:${linkIdx}`; +} +``` + +### 6.2 Visible Edge Types + +```ts +type EndpointRef = + | { kind: "cluster"; id: ClusterId } + | { kind: "entity"; id: EntityId; ownerClusterId: ClusterId }; + +interface AggregatedVisualEdge { + kind: "aggregate"; + visualKey: VisualEdgeKey; + source: EndpointRef & { kind: "cluster" }; + target: EndpointRef & { kind: "cluster" }; + pairKey: PairKey; + typeKey: TypeSetKey | "__collapsed__"; + collapsed: boolean; + count: number; + totalPairCount: number; + distinctTypeCount: number; + color: [number, number, number, number]; + widthPx: number; + transitionParentKeys?: VisualEdgeKey[]; +} + +interface IndividualVisualEdge { + kind: "individual"; + visualKey: VisualEdgeKey; + source: EndpointRef & { kind: "entity" }; + target: EndpointRef & { kind: "entity" }; + linkIdx: number; + linkEntityId: EntityId; + typeKey: TypeSetKey; + count: 1; + color: [number, number, number, number]; + widthPx: number; + transitionParentKeys?: VisualEdgeKey[]; +} + +type VisualEdge = AggregatedVisualEdge | IndividualVisualEdge; + +// Deck.gl LineLayer datum (one per curve segment). +interface LineSegmentDatum { + edgeKey: VisualEdgeKey; + segmentIndex: number; + sourcePosition: [number, number, number]; + targetPosition: [number, number, number]; + color: [number, number, number, number]; + widthPx: number; + logicalEdge: VisualEdge; +} + +interface EdgeFrame { + cutId: string; + visualEdges: VisualEdge[]; + lineSegments: LineSegmentDatum[]; + exactLogicalEdgeCount: number; + renderedLogicalEdgeCount: number; + omittedLogicalEdgeCount: number; + truncated: boolean; +} +``` + +### 6.3 Cut Index + +The visible cut is indexed for fast owner lookup. + +```ts +interface CutIndex { + cutId: string; + itemByClusterId: Map; + cutBlockIds: Set; // edge endpoints (excludes mode: "children") + entityModeClusterIds: Set; // clusters showing individual entities + ownerMemo: Map; +} + +// Walk up the tree to find the visible owner. +function visibleOwnerOfAssignedCluster( + assignedClusterId: ClusterId, + cut: CutIndex, + state: WorkerState, +): ClusterId | undefined { + const cached = cut.ownerMemo.get(assignedClusterId); + if (cached !== undefined || cut.ownerMemo.has(assignedClusterId)) + return cached; + + let c: ClusterId | undefined = assignedClusterId; + while (c !== undefined) { + if (cut.cutBlockIds.has(c)) { + cut.ownerMemo.set(assignedClusterId, c); + return c; + } + c = state.clusters.get(c)?.parentId; + } + + cut.ownerMemo.set(assignedClusterId, undefined); + return undefined; +} +``` + +### 6.4 Base Pair Classification + +Each base pair (between assigned atomic clusters) is classified under the current cut: + +```ts +type BasePairContribution = + | { + kind: "aggregate"; + pairKey: PairKey; + sourceId: ClusterId; + targetId: ClusterId; + } + | { kind: "individual"; ownerClusterId: ClusterId } + | { kind: "hidden"; ownerClusterId?: ClusterId }; + +function classifyBasePair( + baseAgg: EdgeAgg, + cut: CutIndex, + state: WorkerState, + directed: boolean, +): BasePairContribution { + const sourceOwner = visibleOwnerOfAssignedCluster( + baseAgg.sourceId, + cut, + state, + ); + const targetOwner = visibleOwnerOfAssignedCluster( + baseAgg.targetId, + cut, + state, + ); + + if (!sourceOwner || !targetOwner) return { kind: "hidden" }; + + if (sourceOwner !== targetOwner) { + const pair = makePairKey(sourceOwner, targetOwner, directed); + return { + kind: "aggregate", + pairKey: pair.key, + sourceId: pair.sourceId, + targetId: pair.targetId, + }; + } + + if (cut.entityModeClusterIds.has(sourceOwner)) { + return { kind: "individual", ownerClusterId: sourceOwner }; + } + + return { kind: "hidden", ownerClusterId: sourceOwner }; +} +``` + +### 6.5 Bubble Ports + +A **port** is a point on a cluster's boundary where edges enter or exit. For each pair of connected visible clusters (A, B), compute a port on A facing B and a port on B facing A. All edges between A and B are routed through these two ports. + +#### 6.5.1 Port Position + +Baseline: angle from cluster center toward the connected cluster. + +```ts +interface Port { + readonly clusterId: ClusterId; + readonly neighborClusterId: ClusterId; + readonly angle: number; // radians + readonly x: number; + readonly y: number; + readonly edgeCount: number; + readonly distinctTypes: number; +} + +function computePort( + cluster: ClusterNode, + neighbor: ClusterNode, + config: VizConfig, +): Port { + const theta = Math.atan2(neighbor.y - cluster.y, neighbor.x - cluster.x); + return { + clusterId: cluster.id, + neighborClusterId: neighbor.id, + angle: theta, + x: cluster.x + (cluster.r + config.portPaddingWorld) * Math.cos(theta), + y: cluster.y + (cluster.r + config.portPaddingWorld) * Math.sin(theta), + edgeCount: 0, // filled during aggregation + distinctTypes: 0, + }; +} +``` + +#### 6.5.2 Port Slotting and Merging + +When many neighbors lie in similar directions, raw angle-based ports overlap. Use screen-space minimum spacing: + +```ts +function slotPorts( + ports: Port[], + clusterScreenRadius: number, + config: VizConfig, +): Port[] { + const minSepAngle = + config.minPortSpacingPx / Math.max(clusterScreenRadius, 1); + const maxByCircumference = Math.floor((2 * Math.PI) / minSepAngle); + const portCap = Math.min(config.maxPortsPerCluster, maxByCircumference); + + // Sort by desired angle. + ports.sort((a, b) => a.angle - b.angle); + + if (ports.length <= portCap) { + // Nudge angles minimally to satisfy spacing. + return nudgeAngles(ports, minSepAngle); + } + + // Too many: merge by angular sector. + return mergeByAngularSector(ports, portCap); +} +``` + +Screen-space behavior: + +- Small bubble on screen (e.g. 30px radius): 2-4 visible ports. +- Medium bubble: 8-16 ports. +- Large/open bubble: up to `maxPortsPerCluster` (24-32). +- Beyond cap: angular-sector merging. Merged ports aggregate edge counts for tooltips. + +#### 6.5.3 Port Hysteresis + +Ports must not flicker as zoom changes. Cache port assignments per `(clusterId, setOfNeighborClusterIds)` and reuse when the neighbor set hasn't changed. Only recompute when neighbors appear/disappear from the visible cut. + +### 6.6 Edge Geometry: Hierarchical Bezier Routing + +Edges between ports use cubic Bezier curves with control points along outward normals. This produces smooth, predictable paths. + +#### 6.6.1 Aggregate Edge Path + +```ts +interface BezierPath { + readonly p0: [number, number]; // source port + readonly p1: [number, number]; // source control point + readonly p2: [number, number]; // target control point + readonly p3: [number, number]; // target port +} + +function aggregateEdgePath( + sourcePort: Port, + targetPort: Port, + config: VizConfig, +): BezierPath { + const dx = targetPort.x - sourcePort.x; + const dy = targetPort.y - sourcePort.y; + const len = Math.hypot(dx, dy) || 1; + const tension = config.portTension * len; + + // Outward normals from each cluster toward the other. + const nsx = Math.cos(sourcePort.angle); + const nsy = Math.sin(sourcePort.angle); + const ntx = Math.cos(targetPort.angle); + const nty = Math.sin(targetPort.angle); + + return { + p0: [sourcePort.x, sourcePort.y], + p1: [sourcePort.x + nsx * tension, sourcePort.y + nsy * tension], + p2: [targetPort.x + ntx * tension, targetPort.y + nty * tension], + p3: [targetPort.x, targetPort.y], + }; +} +``` + +#### 6.6.2 Fan-Out Inside Clusters + +When a cluster is opened to show entities (or sub-clusters), edges fan out from the port to individual endpoints. The fan uses straight or lightly curved lines from the port to each entity/child position. + +For entities: + +``` +entity position -> port on cluster boundary -> external Bezier -> target port +``` + +For sub-clusters (recursive): + +``` +entity -> child cluster port -> parent cluster port -> external Bezier +``` + +This hierarchical composition means an edge's full path is: + +``` +source entity + -> source leaf port + -> ... ancestor ports ... + -> LCA external segment (Bezier between top-level ports) + -> ... descendant ports ... + -> target leaf port + -> target entity +``` + +Render the full path as a smoothed B-spline or sampled Bezier polyline. + +#### 6.6.3 Edge Width and Type Lanes + +Aggregate edge width encodes connection strength: + +```ts +function edgeWidthPx(count: number): number { + return Math.min(12, 1.5 + 1.5 * Math.sqrt(count)); +} +``` + +For multiple link types between the same pair, use parallel lanes offset tangentially near the port: + +- Render top `maxParallelEdgeTypes` (e.g. 5) as separate lanes. +- Group remaining as "Other". +- Offset each lane by `parallelEdgeSpacingPx` perpendicular to the path. +- Preserve exact counts in tooltips. + +For directed edges: arrowheads only when the aggregate is directionally pure (all links go A->B). Mixed-direction bundles use no arrowhead or a bidirectional indicator. + +#### 6.6.4 Rendering with Deck.gl + +Generate sampled path points in the worker. Render with `PathLayer`: + +```ts +interface RenderEdgePath { + readonly visualKey: string; + readonly path: [number, number][]; // sampled points along the full route + readonly color: [number, number, number, number]; + readonly widthPx: number; + readonly count: number; + readonly typeLabel: string; +} +``` + +Keep a fixed number of sample points per path (e.g. 8-16) for consistent rendering cost and animation-friendly interpolation. + +### 6.7 Transitions + +When the LOD cut changes, aggregate edges split or merge. + +**Cluster opens** (one aggregate -> several child aggregates): + +1. Previous frame: single edge A -> B through parent ports. +2. New frame: edges A1 -> B, A2 -> B, etc. through child ports. +3. Each child edge starts geometrically at the old parent path. +4. Over 150-300ms: parent aggregate fades out, child paths move from parent port to child ports, child opacity fades in. + +**Cluster closes** (several child aggregates -> one parent aggregate): +Reverse of opening. Child edges converge toward parent port, fade out, parent edge fades in. + +**Minimum viable version**: if exact path morphing is too expensive initially, use opacity crossfade. But do NOT snap from one bundle to many individual edges; that looks like flicker. + +**Note**: the click-to-zoom interaction that triggers these transitions has an unresolved DeckGL issue (OrthographicView controlled mode + LinearInterpolator). The transitions should be designed to work regardless of whether the viewport change is animated or instant. + +### 6.8 Incremental Cut Updates + +When the visible cut changes, only links whose visible owner changes need reclassification. Compare old and new cuts, find changed regions, collect affected base pairs, subtract old contributions and add new ones. Since aggregates are additive, this is exact. + +Port assignments are recomputed only when a cluster's set of connected neighbors changes. Unchanged ports keep their positions (hysteresis). + +If more than ~35% of base pairs are affected, fall back to full recomputation. + +### 6.9 Invariants + +1. **Quotient correctness**: for every aggregate edge `(A, B, type)`, its count equals the number of stored links whose endpoint visible owners are A and B with that `linkTypeKey`. +2. **No double counting**: each stored link is classified as exactly one of aggregate/individual/hidden. +3. **Stable semantic transitions**: visual keys are based on semantic identity (cluster pair + type), not array order. +4. **Exact internal state despite visual collapse**: even when rendering a collapsed edge, the worker keeps exact `byType` counts. +5. **Incremental maintainability**: aggregates are additive. +6. **Port stability**: ports do not flicker when the viewport changes smoothly. Port positions are cached per neighbor set. +7. **Hierarchical composition**: the full route from source entity to target entity passes through every ancestor port in the cluster hierarchy. No edge bypasses a boundary it should cross. + +Internally, keep exact `byType` data. Collapse only at render finalization. + +```ts +function explodeAggregateForRendering( + pairKey: PairKey, + agg: EdgeAgg, + config: VizConfig, +): AggregatedVisualEdge[] { + const types = [...agg.byType.values()].sort( + (a, b) => b.count - a.count || a.typeKey.localeCompare(b.typeKey), + ); + + const source = { kind: "cluster" as const, id: agg.sourceId }; + const target = { kind: "cluster" as const, id: agg.targetId }; + + // Too many link types: collapse into one edge. + if (types.length > config.maxParallelEdgeTypes) { + return [ + { + kind: "aggregate", + visualKey: aggVisualKey(pairKey, "__collapsed__"), + source, + target, + pairKey, + typeKey: "__collapsed__", + collapsed: true, + count: agg.totalCount, + totalPairCount: agg.totalCount, + distinctTypeCount: types.length, + color: [128, 128, 128, 220], + widthPx: widthForCount(agg.totalCount), + }, + ]; + } + + // One edge per link type. + return types.map((typeAgg) => ({ + kind: "aggregate", + visualKey: aggVisualKey(pairKey, typeAgg.typeKey), + source, + target, + pairKey, + typeKey: typeAgg.typeKey, + collapsed: false, + count: typeAgg.count, + totalPairCount: agg.totalCount, + distinctTypeCount: types.length, + color: colorForType(typeAgg.typeKey), + widthPx: widthForCount(typeAgg.count), + })); +} + +function widthForCount(count: number): number { + return Math.max(1, Math.min(12, 1 + Math.log2(count + 1))); +} +``` + +Edge cap: if total visual edges exceed `maxRenderedEdges`, keep the highest-count edges and report `truncated: true`. + +### 6.6 Parallel Edge Geometry + +Deck.gl `LineLayer` draws straight segments. Tessellate each curved edge into segments. + +Group visual edges by endpoint pair. Assign each a stable lane. Compute quadratic Bézier curves with lane offset: + +```ts +interface QuadraticCurve { + p0: [number, number]; + p1: [number, number]; // control point + p2: [number, number]; + laneIndex: number; + laneCount: number; +} + +function computeQuadraticCurve( + source: CircleLayout, + target: CircleLayout, + laneIndex: number, + laneCount: number, + worldUnitsPerPixel: number, + config: VizConfig, +): QuadraticCurve { + let dx = target.x - source.x; + let dy = target.y - source.y; + let d = Math.hypot(dx, dy); + if (d < 1e-6) { + dx = 1; + dy = 0; + d = 1; + } + + const ux = dx / d, + uy = dy / d; // unit direction + const nx = -uy, + ny = ux; // unit normal + + const half = (laneCount - 1) / 2; + const lane = laneIndex - half; + + const spacingPx = config.parallelEdgeSpacingPx ?? 7; + const spacing = spacingPx * worldUnitsPerPixel; + const usable = Math.max(1, d - source.r - target.r); + const maxOffset = Math.max(spacing, 0.2 * usable); + const effectiveSpacing = half > 0 ? Math.min(spacing, maxOffset / half) : 0; + const laneOffset = lane * effectiveSpacing; + + const padding = (config.clusterBoundaryPaddingPx ?? 2) * worldUnitsPerPixel; + + // Start/end at cluster boundary, offset by lane. + const p0 = boundaryPoint(source, ux, uy, nx, ny, laneOffset, +1, padding); + const p2 = boundaryPoint(target, ux, uy, nx, ny, laneOffset, -1, padding); + + const curvature = config.parallelEdgeCurvature ?? 1.6; + const mx = (p0[0] + p2[0]) / 2; + const my = (p0[1] + p2[1]) / 2; + const p1: [number, number] = [ + mx + nx * laneOffset * curvature, + my + ny * laneOffset * curvature, + ]; + + return { p0, p1, p2, laneIndex, laneCount }; +} + +// Tessellate into line segments for LineLayer. +function tessellateCurve( + edge: VisualEdge, + curve: QuadraticCurve, + segments = 8, +): LineSegmentDatum[] { + const result: LineSegmentDatum[] = []; + for (let i = 0; i < segments; i++) { + const t0 = i / segments, + t1 = (i + 1) / segments; + const a = evalQuadratic(curve, t0), + b = evalQuadratic(curve, t1); + result.push({ + edgeKey: edge.visualKey, + segmentIndex: i, + sourcePosition: [a[0], a[1], 0], + targetPosition: [b[0], b[1], 0], + color: edge.color, + widthPx: edge.widthPx, + logicalEdge: edge, + }); + } + return result; +} + +function evalQuadratic(c: QuadraticCurve, t: number): [number, number] { + const u = 1 - t; + return [ + u * u * c.p0[0] + 2 * u * t * c.p1[0] + t * t * c.p2[0], + u * u * c.p0[1] + 2 * u * t * c.p1[1] + t * t * c.p2[1], + ]; +} +``` + +### 6.7 Aggregate-to-Individual Transitions + +Use stable semantic keys. Render both old and new frames during a transition interval with cross-fade. + +```ts +function smoothstep(t: number): number { + t = Math.max(0, Math.min(1, t)); + return t * t * (3 - 2 * t); +} + +interface TransitionedSegment extends LineSegmentDatum { + opacityMultiplier: number; + pickable: boolean; +} + +function reconcileFramesForTransition( + previous: EdgeFrame, + next: EdgeFrame, + progress01: number, +): TransitionedSegment[] { + const p = smoothstep(progress01); + const prevEdges = new Map(previous.visualEdges.map((e) => [e.visualKey, e])); + const nextEdges = new Map(next.visualEdges.map((e) => [e.visualKey, e])); + const result: TransitionedSegment[] = []; + + // Edges in new frame: fade in if new, full opacity if stable. + for (const segment of next.lineSegments) { + result.push({ + ...segment, + opacityMultiplier: prevEdges.has(segment.edgeKey) ? 1 : p, + pickable: true, + }); + } + + // Edges only in old frame: fade out. + for (const segment of previous.lineSegments) { + if (!nextEdges.has(segment.edgeKey)) { + result.push({ ...segment, opacityMultiplier: 1 - p, pickable: false }); + } + } + + return result; +} +``` + +For better dissolve: individual edges carry `transitionParentKeys` pointing to the aggregated edge they came from. The main thread can optionally morph new individual edges from the parent aggregate curve before fading them in. + +### 6.8 Incremental Cut Updates + +When the visible cut changes, only links whose visible owner changes need reclassification. Compare old and new cuts, find changed regions, collect affected base pairs, subtract old contributions and add new ones. Since aggregates are additive, this is exact. + +If more than ~35% of base pairs are affected, fall back to full recomputation. + +### 6.9 Invariants + +1. **Quotient correctness**: for every aggregate edge `(A, B, type)`, its count equals the number of stored links whose endpoint visible owners are A and B with that `linkTypeKey`. +2. **No double counting**: each stored link is classified as exactly one of aggregate/individual/hidden. +3. **Stable semantic transitions**: visual keys are based on semantic identity, not array order. +4. **Exact internal state despite visual collapse**: even when rendering a collapsed edge, the worker keeps exact `byType` counts. +5. **Incremental maintainability**: aggregates are additive, so affected base pairs can be subtracted and re-added exactly. + +The only intentional loss of completeness is `maxRenderedEdges`. If hit, report `truncated: true` and preserve exact counts internally. + +--- + +## Section 7: Frontier Detection and Progressive Exploration + +The loaded graph is a materialized subgraph with vertex set `S = seenEntityIds`. A frontier node is an unloaded vertex in the vertex boundary of S: an entity ID mentioned by a loaded link but not in S. + +**Critical rule**: frontier nodes must NOT be inserted into the normal entity store or added to `seenEntityIds`. They live in a separate `FrontierState` and use synthetic render IDs like `frontier:${entityId}`. + +### 7.1 Frontier State + +```ts +type EndpointSide = "left" | "right"; +type FrontierIncidentKey = `${number}:${EndpointSide}`; + +interface FrontierState { + byEntityId: Map; + // Reverse indexes for cleanup and idempotency. + incidentOwnerByKey: Map; + incidentKeysByLinkIdx: Map>; + // Expansion/fetch state. + requestsByEntityId: Map; + dirtyEntityIds: Set; +} + +interface FrontierNodeRecord { + entityId: EntityId; + incidentKeys: Set; + incidents: Map; + + // Aggregated facts known without loading the entity. + loadedRefsByEntityId: Map; + linkTypeCounts: Map; + + // Incidents where the opposite endpoint is also unloaded (no spatial anchor). + unanchoredIncidentKeys: Set; + + preview?: { label?: string; typeKey?: string }; + layout?: FrontierLayout; + firstSeenSeq: number; + lastTouchedSeq: number; +} + +interface FrontierIncident { + key: FrontierIncidentKey; + linkIdx: LinkIdx; + linkEntityId: EntityId; + linkTypeKey: LinkTypeKey; + frontierEntityId: EntityId; + frontierSide: EndpointSide; + oppositeEntityId: EntityId; + oppositeSide: EndpointSide; + oppositeIdx?: EntityIdx; +} + +interface FrontierLoadedRef { + entityId: EntityId; + entityIdx: EntityIdx; + incidentKeys: Set; + linkIdxs: Set; + linkTypeCounts: Map; + weight: number; +} + +interface FrontierLayout { + x: number; + y: number; + visible: boolean; + strategy: "singleAnchor" | "multiAnchor" | "clusterBoundary" | "unanchored"; + anchorEntityIds: EntityId[]; + layoutVersion: number; +} + +interface FrontierRequestRecord { + entityId: EntityId; + requestId: string; + status: "queued" | "loading" | "error" | "succeeded" | "cancelled"; + source: "click" | "bulk" | "background"; + attempt: number; + cursor?: string; + startedAtMs?: number; + finishedAtMs?: number; + lastError?: string; +} +``` + +What a frontier node knows: entityId, which loaded links mention it, link type counts, which loaded entities reference it, a lower bound on its degree. + +What it does NOT know: full properties, true total degree, full label/type, neighborhood beyond loaded links. + +### 7.2 Frontier Detection During Link Ingestion + +Key invariant: + +``` +link.leftIdx === undefined <=> !seenEntityIds.has(link.leftEntityId) +link.rightIdx === undefined <=> !seenEntityIds.has(link.rightEntityId) +``` + +When ingesting a link, unresolved endpoints are registered in both `pendingLinksByEndpointId` and `frontier`: + +```ts +function ingestLinkEntity(entity: EntityForGraph, state: WorkerState): void { + const linkData = entity.linkData!; + const linkIdx = state.links.length; + const leftIdx = state.entityIdToIdx.get(linkData.leftEntityId); + const rightIdx = state.entityIdToIdx.get(linkData.rightEntityId); + + const link: StoredLink = { + idx: linkIdx, + linkEntityId: entity.metadata.recordId.entityId, + leftEntityId: linkData.leftEntityId, + rightEntityId: linkData.rightEntityId, + leftIdx, + rightIdx, + linkTypeKey: makeTypeSetKey( + entity.metadata.entityTypeIds + .map((url) => state.typeInterner.getOrCreate(url)) + .sort((a, b) => a - b), + ), + }; + + state.links.push(link); + state.seenLinkEntityIds.add(link.linkEntityId); + + if (leftIdx === undefined) { + addPendingLink(state, link.leftEntityId, linkIdx); + observeFrontierEndpoint(state, link.leftEntityId, linkIdx, "left"); + } + if (rightIdx === undefined) { + addPendingLink(state, link.rightEntityId, linkIdx); + observeFrontierEndpoint(state, link.rightEntityId, linkIdx, "right"); + } +} +``` + +`observeFrontierEndpoint` creates or updates the frontier placeholder, tracking which loaded entities reference it and via which link types. + +If both endpoints of a link are unresolved, both get frontier entries but are "unanchored" (no visual position yet). When either endpoint later loads, the other immediately becomes anchored and visible. + +### 7.3 Positioning Frontier Nodes + +Anchor-based placement: frontier nodes do not perturb the real graph layout. Computed after the main layout, using loaded neighbors as fixed anchors. + +**Important**: at large scale, most loaded entities are NOT individually visible (they're inside collapsed clusters). Frontier anchors must resolve through the current visible owner: + +- If neighbor entity is individually visible → anchor to entity position. +- Else if neighbor's visible cluster is known → anchor to cluster boundary in the direction of the frontier node. +- Else → anchor to parent cluster centroid. + +```ts +const FRONTIER_SINGLE_DISTANCE = 56; +const FRONTIER_MULTI_DISTANCE = 44; +const FRONTIER_CLUSTER_MARGIN = 32; +const FRONTIER_JITTER = 14; + +function computeFrontierBasePosition( + node: FrontierNodeRecord, + ctx: LayoutContext, +) { + const anchors: WeightedAnchor[] = []; + for (const ref of node.loadedRefsByEntityId.values()) { + // Try entity position first (if individually visible). + let position = ctx.entityPositions.get(ref.entityIdx); + + // Fall back to visible cluster position. + if (!position) { + const visibleCluster = ctx.entityToVisibleCluster?.get(ref.entityIdx); + if (visibleCluster) { + position = { x: visibleCluster.layout.x, y: visibleCluster.layout.y }; + } + } + + if (!position) continue; + anchors.push({ + entityId: ref.entityId, + entityIdx: ref.entityIdx, + position, + weight: ref.weight, + }); + } + + if (anchors.length === 0) return undefined; // unanchored, not visible + + const barycenter = weightedAverage(anchors); + const dominantCluster = getDominantCluster(anchors, ctx); + const clusterCenter = dominantCluster?.centroid ?? ctx.graphCentroid; + let direction = normalize(sub(barycenter, clusterCenter)); + if (!direction) direction = hashUnitVector(node.entityId); + + // Single anchor: place in the outward direction from the anchor. + // Multiple anchors: place at the weighted barycenter, pushed outward. + // Cluster boundary: project onto the cluster boundary + margin. + const distance = + anchors.length === 1 ? FRONTIER_SINGLE_DISTANCE : FRONTIER_MULTI_DISTANCE; + const base = add(barycenter, mul(direction, distance)); + + // Add deterministic jitter perpendicular to direction. + const tangent = perp(direction); + const jitter = hashSigned(node.entityId) * FRONTIER_JITTER; + return add(base, mul(tangent, jitter)); +} +``` + +After computing base positions, run a small frontier-only collision relaxation pass (frontier-frontier repulsion + weak spring back to base position, ~8 iterations). + +Smoothing: if a frontier node already had a position, lerp toward the new one (0.35 blend factor) for visual stability. + +### 7.4 Frontier Rendering + +Frontier nodes and edges use distinct render IDs (`frontier:${entityId}`) to avoid collision with real entities. + +```ts +interface RenderFrontierNode { + id: `frontier:${EntityId}`; + kind: "frontier"; + entityId: EntityId; + x: number; + y: number; + knownIncidentCount: number; + knownLoadedNeighborCount: number; + linkTypes: Array<{ linkTypeKey: LinkTypeKey; count: number }>; + status: "idle" | "queued" | "loading" | "error"; + style: { opacity: number; desaturated: boolean; cursor: "pointer" }; +} + +interface RenderFrontierEdge { + kind: "frontier-edge"; + linkIdx: LinkIdx; + linkTypeKey: LinkTypeKey; + source: RenderNodeId; // entity:${id} or frontier:${id} + target: RenderNodeId; + unresolvedEntityId: EntityId; +} +``` + +Visual treatment: `opacity: 0.42` (0.65 while loading), desaturated, dashed stroke optional. + +### 7.5 Click-to-Expand Flow + +1. User clicks frontier node with `entityId`. +2. Check if already loaded (`seenEntityIds.has`) or already in-flight. +3. Set request status to `"loading"`. +4. Execute GraphQL neighborhood query. Response must include the center entity itself plus its links and connected entities. +5. Ingest entities first (so links in the same batch resolve immediately), then links. +6. `resolvePendingLinksForEndpoint` runs for each new entity, which: + - Seeds the real node's initial position from the frontier placeholder (preserves mental map). + - Resolves link endpoint indices. + - Removes the frontier entry. + - Updates other frontier nodes that now have a new loaded anchor. +7. Incremental re-clustering runs on the affected type-set groups. + +### 7.6 Background Pagination Resolution + +No special-case path needed. When a cursor page naturally loads an entity that was frontier: + +```ts +function resolvePendingLinksForEndpoint( + state: WorkerState, + entityId: EntityId, + entityIdx: EntityIdx, +): void { + const existingFrontier = state.frontier.byEntityId.get(entityId); + + // Preserve visual continuity: real node appears where frontier was. + if (existingFrontier?.layout?.visible) { + state.initialEntityPositions.set(entityIdx, { + x: existingFrontier.layout.x, + y: existingFrontier.layout.y, + }); + } + + // Resolve all pending links for this endpoint. + const pending = state.pendingLinksByEndpointId.get(entityId) ?? []; + for (const linkIdx of new Set(pending)) { + const link = state.links[linkIdx]!; + for (const side of ["left", "right"] as const) { + if ( + entityIdOn(link, side) === entityId && + idxOn(link, side) === undefined + ) { + setIdxOn(link, side, entityIdx); + + // If the OTHER side is still missing, this newly loaded entity + // becomes a loaded anchor for that other frontier node. + const otherSide = side === "left" ? "right" : "left"; + const otherEntityId = entityIdOn(link, otherSide); + if ( + otherEntityId !== entityId && + idxOn(link, otherSide) === undefined + ) { + observeFrontierEndpoint(state, otherEntityId, linkIdx, otherSide); + } + } + } + } + + state.pendingLinksByEndpointId.delete(entityId); + removeFrontierNode(state, entityId); +} +``` + +This handles the two-unloaded-endpoints case correctly: + +1. Link `A -- B` arrives, both unloaded → both get frontier entries but are unanchored. +2. `A` loads → `resolvePendingLinksForEndpoint(A)` resolves A's side and calls `observeFrontierEndpoint(B)` with A as loaded anchor. +3. `B` becomes visible with A as its spatial anchor. + +### 7.7 Bulk "Expand Frontier" Action + +Controlled expansion, not unbounded BFS: + +1. Snapshot all currently visible frontier node IDs. +2. Queue them with priority (prefer visible, high-degree, many loaded neighbors). +3. Rate-limit via token bucket (concurrency limit + requests/second). +4. Do NOT recursively expand newly discovered frontier nodes unless explicitly requested. +5. Deduplicate queued and in-flight IDs. +6. Ingest each response immediately. +7. Drop queued tasks whose entity has already resolved (by background pagination or another expansion). + +```ts +interface ExpandAllFrontiersOptions { + first: number; // page size per neighborhood query + concurrency: number; // max concurrent requests + requestsPerSecond: number; // token bucket refill rate + batchSize?: number; // if backend supports batch queries + includeNewFrontiers?: boolean; // default false + maxDepth?: number; + maxRequests?: number; + retry: { maxAttempts: number; baseDelayMs: number; maxDelayMs: number }; + signal?: AbortSignal; +} +``` + +Priority scoring: + +```ts +function scoreFrontier( + node: FrontierNodeRecord, + viewport: ViewportRect, +): number { + let score = 0; + if (node.layout?.visible && isInsideViewport(node.layout, viewport)) + score += 1000; + score += 50 * Math.log1p(node.incidentKeys.size); + score += 20 * node.loadedRefsByEntityId.size; + return score; +} +``` + +### 7.8 Correctness Invariants + +1. `seenEntityIds.has(id) === entityIdToIdx.has(id)` — always consistent. +2. For every `StoredLink`, each endpoint index is defined exactly when that endpoint entity is loaded. +3. `pendingLinksByEndpointId[id]` contains exactly links whose endpoint `id` is unresolved. +4. `frontier.byEntityId[id]` exists only if `id` is NOT loaded. +5. Every rendered frontier edge corresponds to a real loaded link entity. +6. Every unresolved endpoint of every loaded link is represented in `FrontierState`. +7. Duplicate GraphQL pages are idempotent (keyed by `entityId` / `linkEntityId`). +8. Entity-before-link and link-before-entity arrival orders converge to the same final state. + +--- + +## Section 8: Search + +Search by entity label or type name. Results highlight the matching entity/cluster and navigate the viewport to it. Part of Phase 1. + +_Implementation details TBD after core sections are complete._ + +--- + +## Interaction Summary + +- **Zoom**: semantic zoom controls which LOD level is shown. Per-cluster screen-space radius, with hysteresis. +- **Pan**: standard 2D panning. +- **Click cluster**: zoom into it (animate viewport transition, LOD follows). +- **Click entity**: open entity details (existing `onEntityClick` behavior). +- **Click frontier node**: fetch neighborhood, expand. +- **Drag**: individual entities draggable when visible. Cluster bubbles not draggable. +- **Hover**: tooltip with entity/cluster details. +- **"Expand frontier" button**: batch-expand all frontier nodes. +- **Search**: find and navigate to entity or cluster. + +## Color Assignment + +- **Color**: assigned per cluster (type set). Consistent hue for a type across the visualization. Sub-clusters get variations within the parent hue range. +- **Size**: cluster bubbles scale with entity count (radius ∝ √count). Individual node size reflects edge count. +- **Frontier nodes**: desaturated version of their inferred type color, reduced opacity. +- **Labels**: cluster label = distinctive type name + count. Entity label = `labelProperty` value. + +## File Structure + +``` +apps/hash-frontend/src/pages/shared/graph-visualizer-2/ +├── index.tsx # Public export +├── graph-visualizer-2.tsx # Main component +├── types.ts # Shared type definitions +├── use-cluster-worker.ts # Hook managing the web worker +├── use-semantic-zoom.ts # LOD state management +├── clustering/ +│ ├── worker.ts # Web worker entry point +│ ├── type-set-clustering.ts # Type-set grouping + merging +│ ├── circle-packing.ts # Macro layout +│ ├── force-layout.ts # Micro layout for expanded clusters +│ └── incremental.ts # Patch/update logic +├── rendering/ +│ ├── cluster-layers.ts # Deck.gl layers for cluster bubbles +│ ├── entity-layers.ts # Deck.gl layers for individual entities +│ ├── edge-layers.ts # Deck.gl layers for edges +│ ├── frontier-layers.ts # Deck.gl layers for frontier nodes +│ └── label-layers.ts # Deck.gl layers for text labels +├── interaction/ +│ ├── use-picking.ts # Click/hover handlers via Deck.gl picking +│ ├── use-drag.ts # Entity dragging +│ └── use-viewport.ts # Zoom/pan state +└── exploration/ + ├── use-frontier.ts # Detect frontier nodes + ├── use-expand-neighborhood.ts # Fetch + merge neighborhood + └── use-background-pagination.ts # Incremental data loading +``` + +## Implementation Order + +### Phase 1: Clustering + LOD + +1. ~~Set up Deck.gl with OrthographicView, basic zoom/pan~~ ✓ +2. ~~Web worker with type-set clustering algorithm~~ ✓ +3. ~~Radial layout with collision relaxation~~ ✓ +4. ~~Render cluster bubbles with labels~~ ✓ +5. ~~Semantic zoom: visible cut with hysteresis~~ ✓ +6. ~~Incremental cluster updates~~ ✓ +7. Fix click-to-zoom (DeckGL OrthographicView + LinearInterpolator issue) +8. Force layout for individual entities within expanded clusters (Section 5) +9. Edge rendering with bubble ports (Section 6) +10. Embedding-based subdivision for large same-type clusters (Section 2.7) +11. vec2slug label worker (Section 2.7.5) +12. Color assignment from type sets +13. Interaction: click, hover, tooltips +14. Search with highlight and viewport navigation + +### Phase 2: Progressive Exploration + +1. Frontier detection +2. Background pagination with incremental cluster updates +3. Click-to-expand on frontier nodes +4. "Expand frontier" bulk action +5. Layout stability during incremental updates +6. Animation for cluster growth/splitting + +### Phase 3: Polish + +1. Smooth zoom transitions between LOD levels +2. Performance optimization for extreme scale +3. Drag support for individual entities +4. Server-side clustering for clusters exceeding embeddingClientNodeCap + +## Section 8: Worker ↔ Main-Thread Protocol + +The worker owns clustering, layout, and edge aggregation state. The main thread owns Deck.gl, viewport interaction, and GraphQL. They communicate via typed messages. + +**Critical rule**: the main thread MUST never receive full cluster/entity/link maps. It receives only visible-frame payloads and compact layout arrays. + +### 8.1 Message Types + +```ts +// Main → Worker +type MainToWorkerMessage = + | { type: "INGEST_BATCH"; entities: EntityForGraph[]; batchId: string } + | { type: "INIT_TYPE_REGISTRY"; schemas: Map } + | { type: "VIEWPORT_CHANGED"; viewport: ViewportState; frameId: string } + | { type: "REQUEST_SUBCLUSTERS"; clusterId: ClusterId } + | { type: "REQUEST_MICRO_LAYOUT"; clusterId: ClusterId } + | { type: "CANCEL_JOB"; jobId: string } + | { type: "SEARCH"; query: string; requestId: string }; + +// Worker → Main +type WorkerToMainMessage = + | { + type: "RENDER_FRAME"; + frameId: string; + clusters: RenderCluster[]; + mode: VizMode; + } + | { type: "EDGE_FRAME"; frameId: string; frame: EdgeFrame } + | { + type: "MICRO_LAYOUT_PARTIAL"; + clusterId: ClusterId; + positions: Float32Array; + alpha: number; + } + | { + type: "FRONTIER_FRAME"; + nodes: RenderFrontierNode[]; + edges: RenderFrontierEdge[]; + } + | { type: "JOB_STATUS"; jobId: string; status: string } + | { type: "SEARCH_RESULTS"; requestId: string; results: SearchResult[] } + | { type: "MODE_CHANGED"; oldMode: VizMode; newMode: VizMode } + | { type: "ERROR"; message: string; context?: string }; +``` + +### 8.2 Viewport Throttling + +Viewport changes (pan/zoom) fire at 60fps. The main thread MUST coalesce these: + +- Use `requestAnimationFrame` to batch viewport updates. +- Send at most one `VIEWPORT_CHANGED` per frame. +- The worker processes the latest viewport and discards stale ones. +- Pan/zoom rendering is handled directly by Deck.gl (no worker round-trip needed for basic viewport transforms). The worker is only consulted for LOD cut changes. + +### 8.3 Render Frame Payloads + +```ts +interface RenderCluster { + id: ClusterId; + x: number; + y: number; + r: number; + color: [number, number, number, number]; + label: string; + count: number; + mode: LodMode; +} +``` + +For entity-mode clusters, entity positions are sent as `Float32Array` (transferable, zero-copy) rather than individual objects. + +### 8.4 Cooperative Scheduling + +All heavy worker operations MUST yield to the message loop. Use cooperative time-slicing: + +```ts +function runCooperativeSlice( + work: () => boolean, // returns true if more work remains + onComplete: () => void, + sliceMs: number = 8, +): void { + const deadline = performance.now() + sliceMs; + while (performance.now() < deadline) { + if (!work()) { + onComplete(); + return; + } + } + // Yield to message loop, then continue. + setTimeout(() => runCooperativeSlice(work, onComplete, sliceMs), 0); +} +``` + +This applies to: force simulation, community detection, HAC, large ingestion batches, edge recomputation. + +--- + +## Section 9: Search + +Search over 3M labels requires a dedicated index. + +### 9.1 Strategy + +**Hybrid approach**: + +- **Type/cluster search**: client-side in the worker. The cluster tree is small; searching cluster labels is trivial. +- **Entity search**: prefix trie or trigram index built incrementally in the worker as entities are ingested. At 3M entities with average label length ~30 chars, a compressed trie uses ~100-200MB. If this exceeds the memory budget, fall back to backend search. + +### 9.2 Search Results + +```ts +interface SearchResult { + kind: "cluster" | "entity"; + id: ClusterId | EntityId; + label: string; + typeLabel?: string; + score: number; + // For navigation: where to point the viewport. + targetX: number; + targetY: number; + targetZoom: number; +} +``` + +Results navigate the viewport to the matching entity/cluster and highlight it. If the entity is inside a collapsed cluster, the cluster is highlighted and labeled "Contains: [match]". + +--- + +## Section 10: Config Validation + +On worker initialization, validate config invariants: + +```ts +function validateConfig(config: VizConfig): void { + assert( + config.entityRevealMax <= config.forceMaxNodes, + `entityRevealMax (${config.entityRevealMax}) must be <= forceMaxNodes (${config.forceMaxNodes})`, + ); + assert( + config.flatLayoutMaxNodes < config.flatLayoutExitNodes, + "flatLayoutMaxNodes must be < flatLayoutExitNodes (hysteresis)", + ); + assert( + config.communityColorMaxNodes < config.communityColorExitNodes, + "communityColorMaxNodes must be < communityColorExitNodes (hysteresis)", + ); + assert( + config.flatLayoutExitNodes <= config.communityColorMaxNodes, + "flatLayoutExitNodes must be <= communityColorMaxNodes (no gap between modes)", + ); + assert( + config.closeChildrenRadiusPx < config.openChildrenRadiusPx, + "close threshold must be < open threshold (hysteresis)", + ); + assert( + config.closeEntitiesRadiusPx < config.openEntitiesRadiusPx, + "close threshold must be < open threshold (hysteresis)", + ); + assert( + config.communityMinSize < config.communityMaxSize, + "communityMinSize must be < communityMaxSize", + ); +} +``` + +--- + +## Section 11: Deck.gl Implementation Notes + +- Set `coordinateSystem: COORDINATE_SYSTEM.CARTESIAN` for non-geospatial 2D. +- Cluster radii are in **world/common units**. Individual node radii may be in **pixel units** (use `radiusUnits: 'pixels'`). +- Use **binary attributes** for large layers (entity ScatterplotLayer, edge LineLayer). Do not create object datums for millions of entities. +- `TextLayer` has no automatic label-collision avoidance. Implement label culling: only render labels for clusters above a minimum screen-space radius, prioritized by count. +- `IconLayer` needs a bounded icon atlas. Pre-render entity type icons into a single atlas texture. Do not use arbitrary per-type icon URLs at scale. +- For curved edges, consider `PathLayer` instead of tessellated `LineLayer` segments. `PathLayer` handles multi-segment paths natively. +- Dashed frontier edges require `PathStyleExtension` or a custom shader. `LineLayer` does not support dashes natively. +- Deck.gl transitions are index/attribute-based, not semantic-key-based. The stable visual keys from Section 6 are for our transition reconciliation layer, not Deck.gl's built-in transitions. + +--- + +## Section 12: Edge Aggregation and Subclustering Interaction + +The edge aggregation system (Section 6) uses group-level aggregates that support **coarsening** (rolling atomic clusters up to parents). However, community subclusters (Section 2) are a **refinement** below atomic clusters. Group-level aggregates cannot be split among community children. + +**Rule**: + +- Cuts **coarser** than type-set atomic clusters: use `groupEdgeAgg` and `clusterEdgeAgg` (the quotient approach from Section 6). +- Cuts **finer** than type-set atomic clusters (community subclusters, entity-mode): use the **CSR adjacency index** to classify individual links. Walk the incident links of entities within each community/visible-entity-set. + +The `visibleOwnerOfAssignedCluster` function only walks **up** the tree. For refined clusters, use a separate function: + +```ts +function visibleOwnerOfEntity( + entityIdx: EntityIdx, + cut: CutIndex, + state: WorkerState, +): ClusterId | undefined { + // Check if entity's parent community/bucket is in the cut. + const entityCommunity = state.entityToCommunityCluster?.get(entityIdx); + if (entityCommunity && cut.cutBlockIds.has(entityCommunity)) { + return entityCommunity; + } + + // Fall back to the atomic cluster and walk up. + const group = state.typeSetGroups.get(state.entities[entityIdx]!.typeSetKey)!; + return visibleOwnerOfAssignedCluster(group.assignedClusterId, cut, state); +} +``` + +--- + +## Open Questions + +1. **Cluster size thresholds**: the VizConfig values are initial guesses. Need empirical tuning against the actual 3M dataset. + +2. ~~**Zoomed-in entity limit**~~: resolved by Section 2.7 (embedding k-means subdivision). When link structure is sparse, embedding subdivision takes over. + +3. **Animation budget**: smooth transitions are important but expensive. What's the performance budget on target hardware? + +4. **Color palette**: current code uses a static palette. Do we keep that or design a new palette optimized for cluster hierarchy (hue families with lightness variations for sub-clusters)? + +5. ~~**Sparse sub-clustering fallback**~~: resolved by Section 2.7. Embedding subdivision replaces hash-based partitioning as the fallback for sparse link structure. + +6. **Search memory budget**: a prefix trie over 3M labels may use 100-200MB. Need to determine acceptable memory overhead or fall back to backend search for entity-level queries. + +7. **Embedding API**: what's the GraphQL query to fetch projected (128-D) embeddings for a set of entity IDs? Is this an existing field or does it need to be added? + +8. **vec2slug model hosting**: the 44 MiB ONNX model loads on demand into WASM. Should it be bundled, or loaded from a CDN on first use? Bundling adds to initial page weight; CDN adds a network dependency. + +9. **Click-to-zoom DeckGL issue**: OrthographicView controlled mode with LinearInterpolator doesn't apply the zoom change. Needs investigation, possibly via the oracle with DeckGL source code context. + +10. **Centroid quality for vec2slug**: the model was trained on single-entity embeddings. Cluster centroids (averaged embeddings) may be out-of-distribution. Should we evaluate vec2slug on real cluster centroids to validate quality, or is the medoid fallback sufficient? diff --git a/apps/hash-frontend/src/pages/shared/slide-stack.tsx b/apps/hash-frontend/src/pages/shared/slide-stack.tsx index bc7eb4225f1..b5f42b721be 100644 --- a/apps/hash-frontend/src/pages/shared/slide-stack.tsx +++ b/apps/hash-frontend/src/pages/shared/slide-stack.tsx @@ -6,6 +6,7 @@ import { SlideStackContext } from "./slide-stack/context"; import { DataTypeSlide } from "./slide-stack/data-type-slide"; import { EntitySlide } from "./slide-stack/entity-slide"; import { EntityTypeSlide } from "./slide-stack/entity-type-slide"; +import { LinkTableSlide } from "./slide-stack/link-table-slide"; import { SlideBackForwardCloseBar } from "./slide-stack/slide-back-forward-close-bar"; import type { SlideItem } from "./slide-stack/types"; @@ -93,6 +94,9 @@ const StackSlide = ({ replaceItem={replaceItem} /> )} + {item.kind === "linkTable" && ( + + )} diff --git a/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx b/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx new file mode 100644 index 00000000000..0cdc57b840a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx @@ -0,0 +1,335 @@ +/** + * A slide that lists the underlying link entities of an aggregated "highway" edge + * from the graph visualizer. + * + * It is given only the link entity ids and, like the entity slide, fetches its own + * data: a subgraph containing each link entity plus its source and target endpoints. + * It then feeds that into the same {@link EntitiesTable} used by the entities + * visualizer (via {@link useEntitiesTableData}), so the table looks and behaves + * exactly like the entities table users already know -- including the Source and + * Target columns that link entities populate. + */ +import { useQuery } from "@apollo/client"; +import { Box, Container, Stack, Typography, useTheme } from "@mui/material"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { getRoots } from "@blockprotocol/graph/stdlib"; +import { + type BaseUrl, + type EntityId, + splitEntityId, + type VersionedUrl, +} from "@blockprotocol/type-system"; +import { LoadingSpinner } from "@hashintel/design-system"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { queryEntitySubgraphQuery } from "@local/hash-isomorphic-utils/graphql/queries/entity.queries"; + +import { EntitiesTable } from "../entities-visualizer/entities-table"; +import { useEntitiesTableData } from "../entities-visualizer/use-entities-table-data"; +import { inSlideContainerStyles } from "../shared/slide-styles"; +import { useSlideStack } from "../slide-stack"; + +import type { ColumnSort } from "../../../components/grid/utils/sorting"; +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../graphql/api-types.gen"; +import type { + EntitiesTableRow, + SortableEntitiesTableColumnKey, +} from "../entities-visualizer/entities-table-data"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import type { Filter } from "@local/hash-graph-client"; +import type { ClosedMultiEntityTypesRootMap } from "@local/hash-graph-sdk/ontology"; + +const buildLinkEntityFilter = (linkEntityIds: EntityId[]): Filter => { + if (linkEntityIds.length === 0) { + // A filter that matches nothing, so an empty selection returns no rows. + return { all: [] }; + } + + return { + any: linkEntityIds.map((entityId) => { + const [webId, entityUuid, draftId] = splitEntityId(entityId); + + return { + all: [ + { equal: [{ path: ["webId"] }, { parameter: webId }] }, + { equal: [{ path: ["uuid"] }, { parameter: entityUuid }] }, + ...(draftId + ? [{ equal: [{ path: ["draftId"] }, { parameter: draftId }] }] + : []), + ], + }; + }), + }; +}; + +export const LinkTableSlide = ({ + linkEntityIds, +}: { + linkEntityIds: EntityId[]; +}) => { + const theme = useTheme(); + + const { pushToSlideStack } = useSlideStack(); + + const includeDrafts = useMemo( + () => + linkEntityIds.some( + (entityId) => splitEntityId(entityId)[2] !== undefined, + ), + [linkEntityIds], + ); + + const variables = useMemo( + () => ({ + request: { + filter: buildLinkEntityFilter(linkEntityIds), + // The roots of this query are the link entities themselves, so we resolve + // their own source (left) and target (right) endpoints. These MUST be two + // separate single-edge paths: edges within one path are traversed as a + // chain (link -> left, then left -> its own right), which would never reach + // the link's target. Two paths each take one hop from the link, pulling in + // both endpoints so the Source / Target columns resolve. + traversalPaths: [ + { edges: [{ kind: "has-left-entity", direction: "outgoing" }] }, + { edges: [{ kind: "has-right-entity", direction: "outgoing" }] }, + ], + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }), + [includeDrafts, linkEntityIds], + ); + + const { data, error, loading } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + variables, + }); + + const { tableData, updateTableData } = useEntitiesTableData({}); + + const deserialized = useMemo(() => { + if (!data?.queryEntitySubgraph) { + return undefined; + } + + const response = deserializeQueryEntitySubgraphResponse( + data.queryEntitySubgraph, + ); + + return { + subgraph: response.subgraph, + closedMultiEntityTypesRootMap: response.closedMultiEntityTypes as + | ClosedMultiEntityTypesRootMap + | undefined, + definitions: response.definitions, + }; + }, [data?.queryEntitySubgraph]); + + const subgraph = deserialized?.subgraph; + + useEffect(() => { + if (!deserialized) { + return; + } + + // The roots are exactly the requested link entities (the filter matches them); + // their source/target endpoints come in as non-root vertices via the traversal + // paths, so they resolve the Source/Target columns without becoming rows. + updateTableData({ + appendRows: false, + closedMultiEntityTypesRootMap: + deserialized.closedMultiEntityTypesRootMap ?? {}, + definitions: deserialized.definitions, + entities: getRoots(deserialized.subgraph), + subgraph: deserialized.subgraph, + }); + }, [deserialized, updateTableData]); + + const handleEntityClick = useCallback( + (entityId: EntityId) => { + pushToSlideStack({ kind: "entity", itemId: entityId }); + }, + [pushToSlideStack], + ); + + const handleEntityTypeClick = useCallback( + ({ entityTypeId }: { entityTypeId: VersionedUrl }) => { + pushToSlideStack({ kind: "entityType", itemId: entityTypeId }); + }, + [pushToSlideStack], + ); + + // No data-type conversions apply to a fixed set of links; EntitiesTable still + // requires the setter, so provide a stable no-op. + const noopSetActiveConversions = useCallback(() => {}, []); + + // EntitiesTable drives a number of features (sorting, search, selection, CSV + // export) from caller-owned state. This slide shows a fixed set of links, so it + // simply owns that state locally. + const [sort, setSort] = useState< + ColumnSort & { convertTo?: BaseUrl } + >({ + columnKey: "entityLabel", + direction: "asc", + }); + + const [showSearch, setShowSearch] = useState(false); + const [selectedRows, setSelectedRows] = useState([]); + + const currentlyDisplayedColumnsRef = useRef(null); + const currentlyDisplayedRowsRef = useRef(null); + + const showLoading = loading && !tableData; + const isEmpty = !loading && tableData !== null && tableData.rows.length === 0; + const formattedLinkCount = linkEntityIds.length.toLocaleString(); + + return ( + + + + palette.gray[90], mb: 0.75 }} + > + Bundled links + + palette.gray[70], maxWidth: 640 }} + > + Links represented by the selected graph lane. Open any row to + inspect the link entity and its endpoints. + + + ({ + alignItems: "flex-end", + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.gray[10], + minWidth: 132, + px: 2, + py: 1.25, + })} + > + palette.gray[60] }} + > + Represented + + palette.gray[90], lineHeight: 1.1 }} + > + {formattedLinkCount} + + + + {error ? ( + ({ + border: `1px solid ${palette.red[30]}`, + borderRadius: 1.5, + bgcolor: palette.red[10], + color: palette.red[90], + p: 2, + })} + > + + Could not load links + + + {error.message} + + + ) : showLoading || !subgraph ? ( + ({ + alignItems: "center", + justifyContent: "center", + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.common.white, + minHeight: 360, + width: "100%", + })} + > + + + ) : isEmpty ? ( + ({ + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.gray[10], + color: palette.gray[80], + p: 2, + })} + > + + No links found + + + The lane did not resolve to any link entities. + + + ) : ( + ({ + border: `1px solid ${palette.gray[20]}`, + borderRadius: 1.5, + bgcolor: palette.common.white, + boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)", + overflow: "hidden", + })} + > + + + )} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/slide-stack/types.ts b/apps/hash-frontend/src/pages/shared/slide-stack/types.ts index 4b43c0cd40e..467b37940f6 100644 --- a/apps/hash-frontend/src/pages/shared/slide-stack/types.ts +++ b/apps/hash-frontend/src/pages/shared/slide-stack/types.ts @@ -22,9 +22,21 @@ export type SlideDataTypeItem = { onUpdate?: (dataTypeId: VersionedUrl) => void; }; +export type SlideLinkTableItem = { + kind: "linkTable"; + /** + * Synthetic stack key. The link entities are identified by `linkEntityIds`; + * `itemId` only needs to be stable for this stack entry, so it is derived from + * the ids (see the producer in entities-visualizer.tsx). + */ + itemId: string; + linkEntityIds: EntityId[]; +}; + export type SlideItem = | SlideEntityItem | SlideEntityTypeItem - | SlideDataTypeItem; + | SlideDataTypeItem + | SlideLinkTableItem; export type PushToStackFn = (item: SlideItem) => void; diff --git a/tools/embedding-worker/Dockerfile.postgres b/tools/embedding-worker/Dockerfile.postgres new file mode 100644 index 00000000000..4c7fa08fe2c --- /dev/null +++ b/tools/embedding-worker/Dockerfile.postgres @@ -0,0 +1,10 @@ +FROM postgres:18.1-alpine3.23 + +RUN apk add --no-cache --virtual .build-deps gcc clang19 llvm19 git make musl-dev pkgconf \ + && git clone --branch v0.8.1 https://github.com/pgvector/pgvector.git \ + && (cd /pgvector && make && make install) \ + && rm -rf pgvector \ + && apk del .build-deps \ + && mkdir -p /etc/postgresql/ + +USER postgres diff --git a/tools/embedding-worker/README.md b/tools/embedding-worker/README.md new file mode 100644 index 00000000000..eefc055b5a4 --- /dev/null +++ b/tools/embedding-worker/README.md @@ -0,0 +1,77 @@ +# Bulk Embedding Worker + +Generates embeddings for ~986K HASH entities (~6.17M vectors) via OpenRouter, +mirroring the logic in `apps/hash-ai-worker-ts`. + +## Hetzner box setup + +### Postgres + +```bash +docker compose up -d +docker compose exec postgres pg_isready + +# Restore the dump +gzip -cd supply-chain-ontology-v1.restore-as-postgres.sql.gz \ + | docker compose exec -T postgres psql -U postgres -d postgres -v ON_ERROR_STOP=1 +``` + +### Running + +Requires [uv](https://docs.astral.sh/uv/). No venv setup needed; `uv run` +handles dependencies automatically. + +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/graph" +export OPENROUTER_API_KEY="sk-or-..." + +# 1. Build embedding inputs from Postgres -> inputs.parquet +uv run embed.py prepare + +# 2. Generate embeddings (adaptive concurrency: starts at 4, ramps to 64) +uv run embed.py run +uv run embed.py run -n 8 -m 32 # or tune manually + +# 3. Check progress (from another terminal) +uv run embed.py status + +# 4. If interrupted, just re-run. Completed checkpoints are skipped. +uv run embed.py run + +# 5. Export replayable SQL dumps +uv run embed.py export -o ./dumps +``` + +## Output + +Two gzipped COPY-format SQL files: + +| File | Contents | Rows | +| ------------------------------- | ------------------------------------------------------ | ------ | +| `embeddings-entity-only.sql.gz` | One combined embedding per entity (`property IS NULL`) | ~986K | +| `embeddings-all.sql.gz` | Per-property + combined embeddings | ~6.17M | + +Replay on the target database: + +```bash +gzip -cd embeddings-entity-only.sql.gz | psql $TARGET_DATABASE_URL +``` + +## How it works + +1. **prepare** queries Postgres for entities with non-empty properties, formats + embedding inputs (matching the TS worker: `"Title: value"` per property, + newline-joined combined), and writes `inputs.parquet`. + +2. **run** reads `inputs.parquet`, batches inputs respecting the API limits + (2048 inputs and 300K tokens per request), sends concurrent calls to + OpenRouter, and checkpoints results to `checkpoints/*.parquet`. + Concurrency adapts automatically: ramps up while throughput improves, + backs off on 429s or throughput dips (AIMD). + +3. **export** joins `inputs.parquet` with `checkpoints/*.parquet` via DuckDB + and streams COPY-format SQL through gzip. + +## Cost estimate + +~6.17M inputs, ~50-100 tokens average, at $0.13/M tokens: roughly $10-15. diff --git a/tools/embedding-worker/docker-compose.yml b/tools/embedding-worker/docker-compose.yml new file mode 100644 index 00000000000..55ec0a9a5cd --- /dev/null +++ b/tools/embedding-worker/docker-compose.yml @@ -0,0 +1,25 @@ +services: + postgres: + build: + context: . + dockerfile: Dockerfile.postgres + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + shm_size: 4g + command: + - postgres + - -c + - shared_buffers=2GB + - -c + - work_mem=256MB + - -c + - maintenance_work_mem=1GB + - -c + - max_connections=100 + +volumes: + pgdata: diff --git a/tools/embedding-worker/embed.py b/tools/embedding-worker/embed.py new file mode 100644 index 00000000000..2e0cf7219dd --- /dev/null +++ b/tools/embedding-worker/embed.py @@ -0,0 +1,845 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "asyncpg>=0.30", +# "httpx[http2]>=0.28", +# "tqdm>=4.66", +# "pyarrow>=19", +# "duckdb>=1.3", +# "numpy>=2.0", +# ] +# /// +""" +Bulk HASH entity embedding generation via OpenRouter. + +Mirrors the embedding logic from apps/hash-ai-worker-ts: + - Per property: "Title: value" + - Combined: all property lines joined by newlines + - Model: text-embedding-3-large (3072 dims) + +Usage: + export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/graph" + export OPENROUTER_API_KEY="sk-or-..." + + uv run embed.py prepare + uv run embed.py run + uv run embed.py status + uv run embed.py export -o ./dumps +""" + +from __future__ import annotations + +import argparse +import asyncio +import gzip +import json +import os +import sys +import time +from contextlib import asynccontextmanager +from pathlib import Path + +import array +import ctypes +import gc +import struct +from datetime import datetime, timezone + +import asyncpg +import duckdb +import httpx +import numpy as np +import pyarrow as pa +import pyarrow.parquet as pq +from tqdm import tqdm + +def _release_memory(): + """Force Python GC and return freed arenas to the OS.""" + gc.collect() + try: + ctypes.CDLL("libc.so.6").malloc_trim(0) + except Exception: + pass + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +OPENROUTER_URL = "https://openrouter.ai/api/v1/embeddings" +MODEL = "openai/text-embedding-3-large" + +# API limits per the docs: +# max 2048 inputs per request +# max 8192 tokens per input +# max 300,000 tokens summed across all inputs in a request +# 2048 * 3072-dim = ~80MB JSON response, way too large. +# 128 inputs -> ~5MB response, manageable. +MAX_INPUTS = 128 +MAX_TOTAL_TOKENS = 100_000 +CHARS_PER_TOKEN = 3 # conservative overcount + +MAX_RETRIES = 8 +CHECKPOINT_EVERY = 2_000 # flush to parquet every N embeddings + +# 8192 token per-input limit. Structured data with numbers/URLs tokenizes +# at ~2-3 chars/token, so 16K chars keeps nearly everything intact. +MAX_INPUT_CHARS = 16_000 +INPUTS_FILE = "inputs.parquet" +CHECKPOINT_DIR = "checkpoints" + +_CHECKPOINT_SCHEMA = pa.schema( + [("row_id", pa.int32()), ("embedding", pa.list_(pa.float32()))] +) + +# --------------------------------------------------------------------------- +# Postgres binary COPY format +# --------------------------------------------------------------------------- + +_PG_EPOCH = datetime(2000, 1, 1, tzinfo=timezone.utc) +_PGCOPY_HEADER = b"PGCOPY\n\xff\r\n\0" + struct.pack("!II", 0, 0) +_PGCOPY_TRAILER = struct.pack("!h", -1) + +# Pre-packed constants for row writing +_BIN_FIELD_COUNT = struct.pack("!h", 7) +_BIN_NULL = struct.pack("!i", -1) +_BIN_UUID_LEN = struct.pack("!i", 16) +_BIN_TS_LEN = struct.pack("!i", 8) +_BIN_VEC_DATA_LEN = struct.pack("!i", 2 + 2 + 3072 * 4) +_BIN_VEC_DIMS = struct.pack("!hh", 3072, 0) + + +def _ts_to_pg_us(ts_str: str) -> int: + """Timestamp string to Postgres binary (microseconds since 2000-01-01 UTC).""" + dt = datetime.fromisoformat(ts_str) + delta = dt - _PG_EPOCH + return delta.days * 86_400_000_000 + delta.seconds * 1_000_000 + delta.microseconds + + +def _write_binary_row(f, web_id, entity_uuid, prop, emb, dt_str, tt_str): + """Write one row in Postgres binary COPY format.""" + f.write(_BIN_FIELD_COUNT) + + # UUIDs (16 bytes each) + f.write(_BIN_UUID_LEN) + f.write(bytes.fromhex(web_id.replace("-", ""))) + f.write(_BIN_UUID_LEN) + f.write(bytes.fromhex(entity_uuid.replace("-", ""))) + + # draft_id (NULL) + f.write(_BIN_NULL) + + # property (TEXT or NULL) + if prop is None: + f.write(_BIN_NULL) + else: + prop_b = prop.encode("utf-8") + f.write(struct.pack("!i", len(prop_b))) + f.write(prop_b) + + # embedding (pgvector binary: int16 dims + int16 unused + float32[]) + f.write(_BIN_VEC_DATA_LEN) + f.write(_BIN_VEC_DIMS) + a = array.array("f", emb) + if sys.byteorder == "little": + a.byteswap() + f.write(a.tobytes()) + + # timestamps (int64 microseconds since PG epoch) + f.write(_BIN_TS_LEN) + f.write(struct.pack("!q", _ts_to_pg_us(dt_str))) + f.write(_BIN_TS_LEN) + f.write(struct.pack("!q", _ts_to_pg_us(tt_str))) + + +# --------------------------------------------------------------------------- +# Adaptive concurrency (AIMD) +# --------------------------------------------------------------------------- + + +class Pacer: + """Ramps concurrency up while throughput improves, halves on 429 or dip.""" + + def __init__(self, initial: int = 4, max_c: int = 64): + self.target = initial + self.max_c = max_c + self._active = 0 + self._cond = asyncio.Condition() + + @asynccontextmanager + async def slot(self): + async with self._cond: + await self._cond.wait_for(lambda: self._active < self.target) + self._active += 1 + try: + yield + finally: + async with self._cond: + self._active -= 1 + self._cond.notify() + + async def set_target(self, n: int): + async with self._cond: + self.target = max(1, min(self.max_c, n)) + self._cond.notify_all() + + @property + def active(self): + return self._active + + +# --------------------------------------------------------------------------- +# Embedding input formatting (mirrors TS worker) +# --------------------------------------------------------------------------- + + +def _build_entity_inputs( + properties: dict, titles: dict[str, str] +) -> list[tuple[str | None, str]]: + """Build embedding inputs for one entity. + + Returns [(property_base_url | None, input_text), ...]. + Last element is the combined embedding (property=None). + Empty list if no embeddable properties. + """ + sorted_keys = sorted(properties.keys()) + items: list[tuple[str, str]] = [] + combined = "" + + for key in sorted_keys: + title = titles.get(key) + if title is None: + continue + val = properties[key] + text = f"{title}: {val}" if isinstance(val, str) else f"{title}: {json.dumps(val)}" + combined += f"{text}\n" + items.append((key, text)) + + if not items: + return [] + return [*items, (None, combined)] + + +def _vec_to_pg(emb) -> str: + if hasattr(emb, 'tolist'): + emb = emb.tolist() + return "[" + ",".join(str(v) for v in emb) + "]" + + +# --------------------------------------------------------------------------- +# Batching +# --------------------------------------------------------------------------- + + +def _make_batches( + row_ids: list[int], texts: list[str] +) -> list[tuple[list[int], list[str]]]: + """Group into API-call-sized batches respecting both input count and token limits.""" + batches: list[tuple[list[int], list[str]]] = [] + cur_ids: list[int] = [] + cur_texts: list[str] = [] + cur_tokens = 0 + + for rid, text in zip(row_ids, texts): + # Truncate inputs that would exceed the per-input token limit + if len(text) > MAX_INPUT_CHARS: + text = text[:MAX_INPUT_CHARS] + est = len(text) // CHARS_PER_TOKEN + 1 + if cur_ids and (len(cur_ids) >= MAX_INPUTS or cur_tokens + est > MAX_TOTAL_TOKENS): + batches.append((cur_ids, cur_texts)) + cur_ids, cur_texts, cur_tokens = [], [], 0 + cur_ids.append(rid) + cur_texts.append(text) + cur_tokens += est + + if cur_ids: + batches.append((cur_ids, cur_texts)) + return batches + + +# --------------------------------------------------------------------------- +# API +# --------------------------------------------------------------------------- + + +async def _call_api( + client: httpx.AsyncClient, + api_key: str, + texts: list[str], + pacer: Pacer | None = None, +) -> list[list[float]]: + for attempt in range(MAX_RETRIES): + try: + resp = await client.post( + OPENROUTER_URL, + json={"model": MODEL, "input": texts, "encoding_format": "float"}, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=180.0, + ) + if resp.status_code == 429: + if pacer: + await pacer.set_target(max(1, pacer.target // 2)) + wait = float(resp.headers.get("retry-after", 2**attempt)) + tqdm.write(f" 429 -> c={pacer.target if pacer else '?'}, wait {wait:.0f}s") + await asyncio.sleep(wait) + continue + if resp.status_code >= 500: + tqdm.write(f" {resp.status_code}, retry {attempt+1}/{MAX_RETRIES}") + await asyncio.sleep(2**attempt) + continue + resp.raise_for_status() + + try: + data = resp.json() + except (json.JSONDecodeError, ValueError) as exc: + body_preview = resp.text[:200] if resp.text else "(empty)" + tqdm.write(f" bad JSON (attempt {attempt+1}): {body_preview}") + await asyncio.sleep(2**attempt) + continue + + if "data" not in data: + err_msg = data.get("error", {}) + if isinstance(err_msg, dict): + err_msg = err_msg.get("message", str(data)[:200]) + tqdm.write(f" API error (attempt {attempt+1}): {err_msg}") + await asyncio.sleep(2**attempt) + continue + + return [ + np.array(d["embedding"], dtype=np.float32) + for d in sorted(data["data"], key=lambda x: x["index"]) + ] + except (httpx.TimeoutException, httpx.ConnectError) as exc: + tqdm.write(f" connection error (attempt {attempt+1}): {exc}") + if attempt < MAX_RETRIES - 1: + await asyncio.sleep(2**attempt) + else: + raise + raise RuntimeError(f"API failed after {MAX_RETRIES} retries") + + +# --------------------------------------------------------------------------- +# Checkpoint I/O +# --------------------------------------------------------------------------- + + +def _load_completed(cp_dir: str) -> set[int]: + d = Path(cp_dir) + completed: set[int] = set() + if not d.exists(): + return completed + for f in sorted(d.glob("*.parquet")): + t = pq.read_table(f, columns=["row_id"]) + completed.update(t["row_id"].to_pylist()) + return completed + + +def _next_cp_idx(cp_dir: str) -> int: + d = Path(cp_dir) + if not d.exists(): + return 0 + existing = sorted(d.glob("*.parquet")) + return len(existing) + + +def _flush(cp_dir: str, idx: int, buf: list[tuple[int, np.ndarray]]): + d = Path(cp_dir) + d.mkdir(exist_ok=True) + row_ids = [r for r, _ in buf] + # Stack numpy arrays into a single 2D array, then convert to Arrow + stacked = np.stack([e for _, e in buf]) + # Arrow fixed_size_list from contiguous numpy is zero-copy + flat = pa.array(stacked.ravel(), type=pa.float32()) + embeddings_col = pa.FixedSizeListArray.from_arrays(flat, list_size=stacked.shape[1]) + table = pa.table( + { + "row_id": pa.array(row_ids, type=pa.int32()), + "embedding": embeddings_col, + }, + ) + pq.write_table(table, d / f"{idx:06d}.parquet", compression="zstd") + _release_memory() + + +# --------------------------------------------------------------------------- +# prepare +# --------------------------------------------------------------------------- + + +_INPUTS_SCHEMA = pa.schema( + [ + ("row_id", pa.int32()), + ("web_id", pa.string()), + ("entity_uuid", pa.string()), + ("property", pa.string()), + ("input_text", pa.string()), + ("dt_start", pa.string()), + ("tt_start", pa.string()), + ] +) + +# Flush to parquet every N inputs to bound memory +_PREPARE_FLUSH = 100_000 + + +async def cmd_prepare(pg_url: str): + if Path(INPUTS_FILE).exists(): + meta = pq.read_metadata(INPUTS_FILE) + print(f"{INPUTS_FILE} exists ({meta.num_rows:,} rows), skipping") + return + + pool = await asyncpg.create_pool(pg_url, min_size=1, max_size=2) + try: + async with pool.acquire() as conn: + title_rows = await conn.fetch( + """ + SELECT DISTINCT ON (oi.base_url) + oi.base_url, pt.schema->>'title' AS title + FROM property_types pt + JOIN ontology_ids oi ON pt.ontology_id = oi.ontology_id + ORDER BY oi.base_url, oi.version DESC + """ + ) + titles = {r["base_url"]: r["title"] for r in title_rows} + print(f"loaded {len(titles)} property type titles") + + entity_count_total = await conn.fetchval( + """ + SELECT count(*) + FROM entity_temporal_metadata etm + JOIN entity_editions ee ON etm.entity_edition_id = ee.entity_edition_id + WHERE ee.properties != '{}'::jsonb + AND upper(etm.transaction_time) IS NULL + AND etm.draft_id IS NULL + """ + ) + print(f"{entity_count_total:,} entities to process") + + # Stream with a cursor to avoid loading everything into memory + row_id = 0 + entity_count = 0 + buf: dict[str, list] = {k: [] for k in _INPUTS_SCHEMA.names} + writer = pq.ParquetWriter(INPUTS_FILE, _INPUTS_SCHEMA, compression="zstd") + bar = tqdm(total=entity_count_total, desc="building inputs", unit="ent") + + try: + async with conn.transaction(): + async for ent in conn.cursor( + """ + SELECT etm.web_id::text, etm.entity_uuid::text, ee.properties, + lower(etm.decision_time)::text AS dt_start, + lower(etm.transaction_time)::text AS tt_start + FROM entity_temporal_metadata etm + JOIN entity_editions ee + ON etm.entity_edition_id = ee.entity_edition_id + WHERE ee.properties != '{}'::jsonb + AND upper(etm.transaction_time) IS NULL + AND etm.decision_time @> now() + AND etm.draft_id IS NULL + """, + prefetch=5000, + ): + props = json.loads(ent["properties"]) + inputs = _build_entity_inputs(props, titles) + if not inputs: + bar.update(1) + continue + + entity_count += 1 + for prop_key, text in inputs: + buf["row_id"].append(row_id) + buf["web_id"].append(ent["web_id"]) + buf["entity_uuid"].append(ent["entity_uuid"]) + buf["property"].append(prop_key) + buf["input_text"].append(text) + buf["dt_start"].append(ent["dt_start"]) + buf["tt_start"].append(ent["tt_start"]) + row_id += 1 + + bar.update(1) + + if len(buf["row_id"]) >= _PREPARE_FLUSH: + writer.write_table( + pa.table( + {k: pa.array(v) for k, v in buf.items()}, + schema=_INPUTS_SCHEMA, + ) + ) + buf = {k: [] for k in _INPUTS_SCHEMA.names} + + # Flush remaining + if buf["row_id"]: + writer.write_table( + pa.table( + {k: pa.array(v) for k, v in buf.items()}, + schema=_INPUTS_SCHEMA, + ) + ) + finally: + writer.close() + bar.close() + + print(f"{entity_count:,} entities -> {row_id:,} inputs -> {INPUTS_FILE}") + finally: + await pool.close() + + +# --------------------------------------------------------------------------- +# run +# --------------------------------------------------------------------------- + + +async def cmd_run(api_key: str, initial_c: int, max_c: int): + if not Path(INPUTS_FILE).exists(): + print(f"{INPUTS_FILE} not found, run prepare first", file=sys.stderr) + sys.exit(1) + + # Memory-mapped read: Arrow data stays on disk, paged in on demand + inputs = pq.read_table(INPUTS_FILE, memory_map=True, columns=["row_id", "input_text"]) + completed = _load_completed(CHECKPOINT_DIR) + + # Filter in Arrow without materializing to Python lists + if completed: + completed_arr = pa.array(list(completed), type=pa.int32()) + mask = pa.compute.invert(pa.compute.is_in(inputs["row_id"], value_set=completed_arr)) + remaining = inputs.filter(mask) + del completed_arr, mask + else: + remaining = inputs + del inputs + + total = len(remaining) + if total == 0: + print("all done") + return + + # Count batches without materializing all texts + num_batches = (total + MAX_INPUTS - 1) // MAX_INPUTS + print(f"{total:,} remaining across ~{num_batches:,} API calls") + + pacer = Pacer(initial=initial_c, max_c=max_c) + bar = tqdm(total=total, unit="emb", smoothing=0.05) + buf: list[tuple[int, list[float]]] = [] + cp_idx = _next_cp_idx(CHECKPOINT_DIR) + failed = 0 + stop = asyncio.Event() + + async with httpx.AsyncClient( + http2=True, + limits=httpx.Limits(max_connections=max_c + 10), + ) as client: + + async def process(batch_ids: list[int], batch_texts: list[str]): + nonlocal buf, cp_idx, failed + async with pacer.slot(): + try: + embs = await _call_api(client, api_key, batch_texts, pacer) + except Exception as exc: + failed += len(batch_ids) + tqdm.write(f" batch failed ({len(batch_ids)}): {exc}") + bar.update(len(batch_ids)) + return + + buf.extend(zip(batch_ids, embs)) + bar.update(len(batch_ids)) + + if len(buf) >= CHECKPOINT_EVERY: + _flush(CHECKPOINT_DIR, cp_idx, buf) + cp_idx += 1 + buf = [] + + async def adapt(): + prev_n, prev_t, prev_tp = 0, time.monotonic(), 0.0 + while not stop.is_set(): + await asyncio.sleep(10) + now = time.monotonic() + dt = now - prev_t + tp = (bar.n - prev_n) / dt if dt > 0 else 0 + + if prev_tp == 0 or tp >= prev_tp * 0.7: + await pacer.set_target(pacer.target + 2) + else: + await pacer.set_target(pacer.target - 4) + + tqdm.write(f" c={pacer.target} active={pacer.active} {tp:.0f} emb/s") + prev_n, prev_t, prev_tp = bar.n, now, tp + + monitor = asyncio.create_task(adapt()) + + # Process batches lazily: only materialize one Arrow chunk at a time + # and keep at most pacer.target tasks alive + pending: set[asyncio.Task] = set() + + for record_batch in remaining.to_batches(max_chunksize=MAX_INPUTS): + batch_ids = record_batch["row_id"].to_pylist() + batch_texts = record_batch["input_text"].to_pylist() + # Truncate oversized inputs + batch_texts = [ + t[:MAX_INPUT_CHARS] if len(t) > MAX_INPUT_CHARS else t + for t in batch_texts + ] + + # Backpressure: wait if we have too many in-flight tasks + while len(pending) >= max_c: + done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + + task = asyncio.create_task(process(batch_ids, batch_texts)) + pending.add(task) + + # Drain remaining tasks + if pending: + await asyncio.gather(*pending) + + stop.set() + monitor.cancel() + del remaining + + if buf: + _flush(CHECKPOINT_DIR, cp_idx, buf) + + bar.close() + done_total = len(completed) + total - failed + all_total = pq.read_metadata(INPUTS_FILE).num_rows + print(f"\n{done_total:,}/{all_total:,} complete") + if failed: + print(f"{failed:,} failed (run again to retry)") + + +# --------------------------------------------------------------------------- +# status +# --------------------------------------------------------------------------- + + +def cmd_status(): + if not Path(INPUTS_FILE).exists(): + print("no inputs file, run prepare first") + return + total = pq.read_metadata(INPUTS_FILE).num_rows + completed = len(_load_completed(CHECKPOINT_DIR)) + entity_level = duckdb.sql( + f"SELECT count(*) FROM read_parquet('{INPUTS_FILE}') WHERE property IS NULL" + ).fetchone()[0] + + print(f"inputs: {total:,} ({entity_level:,} entity-level, {total - entity_level:,} per-property)") + if total: + print(f"completed: {completed:,} ({completed / total * 100:.1f}%)") + print(f"remaining: {total - completed:,}") + + +# --------------------------------------------------------------------------- +# export +# --------------------------------------------------------------------------- + + +def _export_worker_binary(args: tuple) -> tuple[str, int]: + """Process checkpoint files, write binary COPY part. Much faster than text.""" + cp_files_chunk, entity_only, part_path, worker_id, inputs_file = args + count = 0 + total_files = len(cp_files_chunk) + where = "WHERE i.property IS NULL" if entity_only else "" + db = duckdb.connect() + + with gzip.open(part_path, "wb", compresslevel=4) as f: + batch_size = 10 + for fi in range(0, total_files, batch_size): + batch_files = cp_files_chunk[fi : fi + batch_size] + file_list = ", ".join(f"'{p}'" for p in batch_files) + + result = db.execute(f""" + SELECT i.web_id, i.entity_uuid, i.property, + e.embedding, i.dt_start, i.tt_start + FROM read_parquet([{file_list}]) e + JOIN read_parquet('{inputs_file}') i ON e.row_id = i.row_id + {where} + """) + + while rows := result.fetchmany(5000): + for web_id, entity_uuid, prop, emb, dt, tt in rows: + _write_binary_row(f, web_id, entity_uuid, prop, emb, dt, tt) + count += 1 + + done = min(fi + batch_size, total_files) + if done % 25 < batch_size or done == total_files: + print(f" w{worker_id}: {done}/{total_files} files, {count:,} rows", flush=True) + + db.close() + return part_path, count + + +def _export_worker(args: tuple) -> tuple[str, int]: + """Process a chunk of checkpoint files, write a gzip part. Runs in forked process.""" + cp_files_chunk, entity_only, part_path, worker_id, inputs_file = args + count = 0 + total_files = len(cp_files_chunk) + where = "WHERE i.property IS NULL" if entity_only else "" + db = duckdb.connect() + + with gzip.open(part_path, "wt", compresslevel=4) as f: + # Process checkpoint files in small batches for efficient DuckDB joins + batch_size = 10 + for fi in range(0, total_files, batch_size): + batch_files = cp_files_chunk[fi : fi + batch_size] + file_list = ", ".join(f"'{p}'" for p in batch_files) + + result = db.execute(f""" + SELECT i.web_id, i.entity_uuid, i.property, + e.embedding, i.dt_start, i.tt_start + FROM read_parquet([{file_list}]) e + JOIN read_parquet('{inputs_file}') i + ON e.row_id = i.row_id + {where} + """) + + while rows := result.fetchmany(5000): + for web_id, entity_uuid, prop, emb, dt, tt in rows: + f.write( + "\t".join( + [ + web_id, + entity_uuid, + "\\N", + prop if prop is not None else "\\N", + _vec_to_pg(emb), + dt, + tt, + ] + ) + + "\n" + ) + count += 1 + + done = min(fi + batch_size, total_files) + if done % 25 < batch_size or done == total_files: + print(f" w{worker_id}: {done}/{total_files} files, {count:,} rows", flush=True) + + db.close() + return part_path, count + + +def cmd_export(output_dir: str, num_export_workers: int = 8, fmt: str = "binary"): + import multiprocessing as mp + + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + cp_files = sorted(Path(CHECKPOINT_DIR).glob("*.parquet")) + if not cp_files: + print("no checkpoints to export") + return + + num_workers = min(mp.cpu_count() or 4, num_export_workers) + cols = ( + "web_id, entity_uuid, draft_id, property, embedding, " + "updated_at_decision_time, updated_at_transaction_time" + ) + + use_binary = fmt == "binary" + ext = ".bin.gz" if use_binary else ".sql.gz" + worker_fn = _export_worker_binary if use_binary else _export_worker + + for label, entity_only, basename in [ + ("entity-only", True, "embeddings-entity-only"), + ("all", False, "embeddings-all"), + ]: + filename = basename + ext + path = out / filename + parts_dir = out / f".parts_{label}" + parts_dir.mkdir(exist_ok=True) + if path.exists(): + size_mb = path.stat().st_size / (1024 * 1024) + print(f" {filename} already exists ({size_mb:,.1f} MB), skipping") + continue + + print(f"exporting {label} ({fmt}) with {num_workers} workers...") + + # Split checkpoint files across workers + chunks = [[] for _ in range(num_workers)] + for i, f in enumerate(cp_files): + chunks[i % num_workers].append(f) + + work = [ + (chunk, entity_only, str(parts_dir / f"part_{i:03d}.gz"), i, str(Path(INPUTS_FILE).resolve())) + for i, chunk in enumerate(chunks) + if chunk + ] + + ctx = mp.get_context("fork") + total_count = 0 + with ctx.Pool(num_workers) as pool: + for part_path, count in pool.imap_unordered(worker_fn, work): + total_count += count + print(f" {Path(part_path).name}: {count:,} rows") + + # Concatenate: gzip concat is valid (decompresses to concatenation) + print(f" concatenating {len(work)} parts...") + with open(path, "wb") as out_f: + if use_binary: + out_f.write(gzip.compress(_PGCOPY_HEADER)) + else: + out_f.write(gzip.compress( + f"COPY entity_embeddings ({cols}) FROM stdin;\n".encode() + )) + + for w in sorted(work, key=lambda x: x[2]): + with open(w[2], "rb") as pf: + while chunk := pf.read(1024 * 1024): + out_f.write(chunk) + + if use_binary: + out_f.write(gzip.compress(_PGCOPY_TRAILER)) + else: + out_f.write(gzip.compress(b"\\.\n")) + + # Clean up parts + for w in work: + Path(w[2]).unlink(missing_ok=True) + parts_dir.rmdir() + + size_mb = path.stat().st_size / (1024 * 1024) + print(f" {filename}: {total_count:,} rows ({size_mb:,.1f} MB)") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(): + p = argparse.ArgumentParser(description="Bulk HASH entity embedding generation") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("prepare", help="build embedding inputs from Postgres into inputs.parquet") + + run_p = sub.add_parser("run", help="generate embeddings via OpenRouter") + run_p.add_argument("-n", "--initial-concurrency", type=int, default=4, help="starting concurrency (default: 4)") + run_p.add_argument("-m", "--max-concurrency", type=int, default=64, help="concurrency ceiling (default: 64)") + + sub.add_parser("status", help="show progress") + + exp_p = sub.add_parser("export", help="export replayable SQL dumps") + exp_p.add_argument("-o", "--output", default=".", help="output directory") + exp_p.add_argument("-w", "--workers", type=int, default=4, help="parallel workers (default: 4)") + exp_p.add_argument("-f", "--format", choices=["binary", "text"], default="binary", + help="binary (faster, smaller) or text (COPY FROM stdin)") + + + args = p.parse_args() + + match args.cmd: + case "prepare": + db_url = os.environ.get("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/graph") + asyncio.run(cmd_prepare(db_url)) + case "run": + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + print("set OPENROUTER_API_KEY", file=sys.stderr) + sys.exit(1) + asyncio.run(cmd_run(api_key, args.initial_concurrency, args.max_concurrency)) + case "status": + cmd_status() + case "export": + cmd_export(args.output, args.workers, args.format) + + +if __name__ == "__main__": + main() diff --git a/tools/embedding-worker/test_api.py b/tools/embedding-worker/test_api.py new file mode 100644 index 00000000000..f23d656e62f --- /dev/null +++ b/tools/embedding-worker/test_api.py @@ -0,0 +1,28 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = ["httpx"] +# /// +import httpx, os, sys, json + +api_key = os.environ.get("OPENROUTER_API_KEY") +if not api_key: + print("set OPENROUTER_API_KEY"); sys.exit(1) + +resp = httpx.post( + "https://openrouter.ai/api/v1/embeddings", + json={"model": "openai/text-embedding-3-large", "input": ["hello world"], "encoding_format": "float"}, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, +) +print(f"status: {resp.status_code}") +print(f"headers: {dict(resp.headers)}") +body = resp.text[:500] +print(f"body: {body}") + +if resp.status_code == 200: + data = resp.json() + if "data" in data: + print(f"embedding dims: {len(data['data'][0]['embedding'])}") + else: + print(f"unexpected response: {json.dumps(data, indent=2)[:500]}") From 30a58fbdd3f27a7b7d5ec645d1d42b6b6bc4a7e3 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:12:58 +0200 Subject: [PATCH 40/78] feat: performance + code review --- .../shared/graph-visualizer-2/math/color.ts | 95 ++++ .../shared/graph-visualizer-2/math/hash.ts | 97 ++++ .../shared/graph-visualizer-2/visual-style.ts | 43 +- .../worker/.impeccable/hook.cache.json | 22 - .../graph-visualizer-2/worker/PERFORMANCE.md | 446 ++++++++++++++++++ .../worker/bench-fixtures.ts | 212 +++++++++ .../worker/buffers/growable-buffer.bench.ts | 101 ++++ .../worker/core/commit-rebuild.bench.ts | 162 +++++++ .../graph-visualizer-2/worker/csr-graph.ts | 53 ++- .../worker/entity-id-codec.test.ts | 1 - .../worker/entity-id-codec.ts | 91 ++-- .../worker/entity-style.test.ts | 1 - .../graph-visualizer-2/worker/entity-style.ts | 116 ++--- .../shared/graph-visualizer-2/worker/entry.ts | 19 +- .../worker/geometry/edge-aggregation.bench.ts | 54 +++ .../worker/hierarchy/cluster-tree.ts | 3 +- .../worker/hierarchy/community.bench.ts | 92 ++++ .../worker/layout/force-simulation.bench.ts | 110 +++++ .../graph-visualizer-2/worker/protocol.ts | 123 ++--- .../graph-visualizer-2/worker/random.test.ts | 1 - .../graph-visualizer-2/worker/random.ts | 18 +- .../worker/stores/ingestion.bench.ts | 132 ++++++ 22 files changed, 1690 insertions(+), 302 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/color.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/color.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/color.ts new file mode 100644 index 00000000000..f57fdce3e54 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/color.ts @@ -0,0 +1,95 @@ +/* eslint-disable id-length */ +/** Color space conversions. */ + +// Conversion matrices from Björn Ottosson's OKLab specification. +// https://bottosson.github.io/posts/oklab/ + +/** sRGB gamma encoding (linear to sRGB transfer function). */ +function gammaEncode(linear: number): number { + if (linear >= 0.0031308) { + return 1.055 * linear ** (1 / 2.4) - 0.055; + } + return 12.92 * linear; +} + +function clampByte(value: number): number { + return Math.round(Math.max(0, Math.min(1, value)) * 255); +} + +/** + * Convert an OKLCH color to sRGB. + * + * `lightness` in [0, 1], `chroma` in [0, ~0.4] (sRGB gamut), `hue` in + * [0, 360). Returns [r, g, b] each in [0, 255], clamped to sRGB. + */ +export function oklchToRgb( + lightness: number, + chroma: number, + hue: number, +): [number, number, number] { + // OKLCH to OKLab (polar to cartesian). + const hRad = (hue * Math.PI) / 180; + const a = chroma * Math.cos(hRad); + const b = chroma * Math.sin(hRad); + + // OKLab to LMS (cube roots). + const l_ = lightness + 0.3963377774 * a + 0.2158037573 * b; + const m_ = lightness - 0.1055613458 * a - 0.0638541728 * b; + const s_ = lightness - 0.0894841775 * a - 1.291485548 * b; + + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + + // LMS to linear sRGB. + const rLin = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + + return [ + clampByte(gammaEncode(rLin)), + clampByte(gammaEncode(gLin)), + clampByte(gammaEncode(bLin)), + ]; +} + +/** HSL to sRGB. `hue` in [0, 360), `saturation` and `lightness` in [0, 1]. */ +export function hslToRgb( + hue: number, + saturation: number, + lightness: number, +): [number, number, number] { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const sextant = hue / 60; + const second = chroma * (1 - Math.abs((sextant % 2) - 1)); + const match = lightness - chroma / 2; + + let red = 0; + let green = 0; + let blue = 0; + if (sextant < 1) { + red = chroma; + green = second; + } else if (sextant < 2) { + red = second; + green = chroma; + } else if (sextant < 3) { + green = chroma; + blue = second; + } else if (sextant < 4) { + green = second; + blue = chroma; + } else if (sextant < 5) { + red = second; + blue = chroma; + } else { + red = chroma; + blue = second; + } + + return [ + Math.round((red + match) * 255), + Math.round((green + match) * 255), + Math.round((blue + match) * 255), + ]; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts new file mode 100644 index 00000000000..9bb5e4db77d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts @@ -0,0 +1,97 @@ +/* eslint-disable no-bitwise, id-length, no-param-reassign */ + +function add32(a: number, b: number): number { + return (a + b) >>> 0; +} + +function mul32(a: number, b: number): number { + return Math.imul(a, b) >>> 0; +} + +function rotl32(x: number, r: number): number { + return ((x << r) | (x >>> (32 - r))) >>> 0; +} + +function getBlock32(key: Uint8Array, i: number): number { + const offset = i * 4; + + return ( + (key[offset]! | + (key[offset + 1]! << 8) | + (key[offset + 2]! << 16) | + (key[offset + 3]! << 24)) >>> + 0 + ); +} + +function fmix32(k: number): number { + k ^= k >>> 16; + k = mul32(k, 0x85ebca6b); + k ^= k >>> 13; + k = mul32(k, 0xc2b2ae35); + k ^= k >>> 16; + return k; +} + +/** + * MurmurHash3 x86 32-bit. + * + * Vendored and adapted from https://github.com/timepp/murmurhash/tree/master + */ +export function murmur3(key: Uint8Array, seed: number = 0): number { + let h1 = seed >>> 0; + + const length = key.length; + const blocks = Math.floor(length / 4); + + const c1 = 0xcc9e2d51; + const c2 = 0x1b873593; + + // body + for (let i = 0; i < blocks; i++) { + let k1 = getBlock32(key, i); + k1 = mul32(k1, c1); + k1 = rotl32(k1, 15); + k1 = mul32(k1, c2); + h1 ^= k1; + h1 = rotl32(h1, 13); + h1 = add32(mul32(h1, 5), 0xe6546b64); + } + + const tail = key.slice(blocks * 4); + + let k1 = 0; + + switch (tail.length) { + case 3: + k1 ^= tail[2]! << 16; // fallthrough + case 2: + k1 ^= tail[1]! << 8; // fallthrough + case 1: + k1 ^= tail[0]! << 0; + k1 = mul32(k1, c1); + k1 = rotl32(k1, 15); + k1 = mul32(k1, c2); + h1 ^= k1; + } + + // finalization + h1 ^= length; + h1 = fmix32(h1); + + return h1; +} + +export function murmur3String(key: string, seed: number = 0): number { + return murmur3(new TextEncoder().encode(key), seed); +} + +/** + * Map a string to the unit interval [0, 1) via MurmurHash3. + * + * Deterministic: the same string always produces the same value, + * regardless of call order or session. + */ +export function murmur3StringUnit(value: string): number { + return murmur3String(value) / 0x100000000; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts index 1a2d17cd8b7..e02d88bc352 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/visual-style.ts @@ -1,3 +1,5 @@ +import { hslToRgb } from "./math/color"; + import type { Color } from "./frames"; export const graphCanvasBackground = "#F7FAFC"; @@ -23,47 +25,6 @@ export const graphColors = { edgeLabelBackground: [255, 255, 255, 230], } as const satisfies Record; -/** Compact HSL to RGB conversion for deterministic graph palettes. */ -export function hslToRgb( - hue: number, - saturation: number, - lightness: number, -): [number, number, number] { - const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; - const sextant = hue / 60; - const second = chroma * (1 - Math.abs((sextant % 2) - 1)); - const match = lightness - chroma / 2; - - let red = 0; - let green = 0; - let blue = 0; - if (sextant < 1) { - red = chroma; - green = second; - } else if (sextant < 2) { - red = second; - green = chroma; - } else if (sextant < 3) { - green = chroma; - blue = second; - } else if (sextant < 4) { - green = second; - blue = chroma; - } else if (sextant < 5) { - red = second; - blue = chroma; - } else { - red = chroma; - blue = second; - } - - return [ - Math.round((red + match) * 255), - Math.round((green + match) * 255), - Math.round((blue + match) * 255), - ]; -} - export function colorWithAlpha( color: readonly [number, number, number], alpha: number, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json deleted file mode 100644 index 1e8973dc4a6..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/.impeccable/hook.cache.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 1, - "sessions": { - "8e52f36d-22df-4380-830f-8109a1e0fd71": { - "updatedAt": 1782671897211, - "files": { - "/Users/bmahmoud/projects/hash/hash/apps/hash-frontend/src/pages/shared/slide-stack/link-table-slide.tsx": { - "editCount": 6, - "findings": [] - }, - "/Users/bmahmoud/projects/hash/hash/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts": { - "editCount": 1, - "findings": [] - }, - "/Users/bmahmoud/projects/hash/hash/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts": { - "editCount": 2, - "findings": [] - } - } - } - } -} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md new file mode 100644 index 00000000000..a804618a3b7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md @@ -0,0 +1,446 @@ +# Graph‑visualizer‑2 worker — performance investigation + +This report covers the worker half of the v2 graph visualizer +(`apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker`). It documents +the methodology, the benchmarks that were added, the measured numbers, the +concrete inefficiencies found (with `file:line` references and severity), the +recommended fixes ordered by impact/effort, and the tests that should be added +to lock in the wins. + +Headline findings: + +- **A no‑op `commitStructure()` is not free.** In the flat/community tier every + commit unconditionally re‑sorts all node indices, rescans topology with two + throwaway `Set`s, re‑writes per‑node style for the whole graph, and rebuilds + every render edge — even when nothing changed. Measured **8.1 ms per no‑op + commit at 1,500 nodes** (`core/commit-rebuild.bench.ts`). +- **Streaming ingest pays that cost per batch.** The same 2,000‑node / + 5,000‑link graph costs **46.7 ms delivered as one batch+commit vs 1,018 ms + delivered as 100‑entity batches — a 21.8× penalty**. +- **Community detection is dominated by `boundedLabelPropagation`**, which + allocates a fresh `Map` per node per iteration (≤20 iterations): **111 ms for + a 20k‑node component**, ~400–600× the cost of the connected‑components pass. +- **Buffers are handled well.** `FlatGraphBuffer` grows with 1.5× geometric + slack (`flatCapacityFor`) and `Column` doubles; both stay amortized O(N). A + naïve grow‑to‑exact‑count path would be **18× slower at 50k records**, so this + is worth a regression test, not a fix. + +--- + +## 1. Methodology + +### Fixtures + +All benchmarks use deterministic synthetic graphs from a new shared helper, +`bench-fixtures.ts`, built on the worker's own `mulberry32` PRNG so a given +`(nodeCount, linkCount, seed)` always yields the same graph. It emits the exact +shapes the hot paths consume: + +- `buildIngestEntities(shape)` → `IngestEntity[]` for the ingest/commit path. +- `buildForceGraph(shape)` → `ForceNode[]` / `ForceEdge[]` for the layout engines. +- `buildCommunityInputs(shape)` → a `LinkStore` + entity‑index `Column` for the + community‑detection pipeline. + +Link endpoints are hub‑biased (≈70 % of links point at one of the first +`hubCount` nodes) so the degree distribution resembles real query results rather +than a uniform random graph. + +### Tooling + +Benchmarks are Vitest `bench`/`describe` suites in colocated `*.bench.ts` files. + +**`vitest bench` works in this app unchanged** — no config edits were needed. The +only wrinkle is that the `vitest` binary is hoisted to the monorepo root (the app +has no `node_modules/.bin/vitest`), so it must be invoked by path. From the app +directory: + +```bash +cd apps/hash-frontend +../../node_modules/.bin/vitest bench --run +``` + +(If your shell resolves the hoisted bin on `PATH`, `yarn vitest bench --run …` +also works; the explicit path is the reliable form.) + +### Measurement caveats + +- Numbers below are from a single machine (Apple Silicon, darwin, Node under + Vitest 4.1.8), one run each. **Absolute times vary run‑to‑run** — the entity + interner micro‑bench moved ~2× between two runs on an otherwise idle machine — + so treat absolute ms as order‑of‑magnitude. **The ratios (e.g. bulk vs + streaming, once vs twice, presized vs grown) are stable** and are where the + findings live. +- The heavy layout settle benches and the fresh‑worker commit benches use a + fixed small iteration count (`iterations: 6`) rather than a time budget, + because a single settle can take seconds. Their RME is reported per row. +- The `GraphWorker` runs its layout scheduler on `MessageChannel`s. A synchronous + bench body never lets those macrotasks fire mid‑measurement, so scheduler ticks + do not inflate individual samples; Vitest tears the process down at the end. + +--- + +## 2. Benchmark results + +### 2.1 Store ingestion — `stores/ingestion.bench.ts` + +Per‑entity type‑set keying, entity interning, and link adjacency. Mean time for +one full pass over the whole graph. + +| Case | small (1k/2k) | medium (5k/10k) | large (20k/40k) | +| ---------------------------------------------------- | ------------- | --------------- | --------------- | +| type‑set: `getOrCreate` **once**/node | 0.088 ms | 0.437 ms | 1.70 ms | +| type‑set: `getOrCreate` **twice**/node (peek+insert) | 0.169 ms | 0.846 ms | 3.32 ms | +| `EntityStore.tryInsert`/node | 0.62 ms | 3.50 ms | 14.2 ms | +| build `LinkStore` + `linksForEntity` over all nodes | 0.22 ms | 1.64 ms | 10.1 ms | + +The peek+insert row is a stable **~1.9× the once row** at every size — the +`ingestBatch` code path really does key each node entity twice (see F4). + +### 2.2 Community detection — `hierarchy/community.bench.ts` + +| Stage | 2k/6k | 8k/24k | 20k/60k | +| --------------------------------------------- | -------- | -------- | ------- | +| `buildInducedCsr` | 0.23 ms | 1.52 ms | 6.70 ms | +| `connectedComponents` | 0.022 ms | 0.078 ms | 0.29 ms | +| `boundedLabelPropagation` (largest component) | 10.1 ms | 41.3 ms | 111 ms | +| full pipeline (CSR + components + label prop) | 10.4 ms | 47.3 ms | 122 ms | + +`connectedComponents` is ~10–23× faster than the CSR build and ~400–600× faster +than label propagation. **Label propagation is the whole cost** (see F3). + +### 2.3 Buffer write + growth — `buffers/growable-buffer.bench.ts` + +Mean time to write / grow the whole buffer. + +| Flat‑tier buffer | 1k | 10k | 50k | +| ------------------------------------ | --------- | --------------- | ------------------- | +| presized: write all records + commit | 0.0024 ms | 0.026 ms | 0.096 ms | +| geometric growth (1.5×, production) | 0.0029 ms | 0.073 ms (2.8×) | 0.45 ms (4.7×) | +| fixed‑step growth (1024, naïve) | 0.0024 ms | 0.109 ms (4.2×) | 1.75 ms (**18.3×**) | + +| Other | 1k | 10k | 50k | +| ------------------------------------------------------------------- | --------- | --------- | -------- | +| `EntityPositionBuffer.setPosition`×N + commit (per‑tick leaf write) | 0.0015 ms | 0.0093 ms | 0.064 ms | +| `Column.push`×N (geometric) | 0.0013 ms | 0.024 ms | 0.114 ms | + +Production’s 1.5× geometric growth (`flatCapacityFor`) tracks the presized floor +within ~5×; the naïve grow‑to‑exact‑count path is 18× worse at 50k because every +step re‑allocates and memcpies the whole (non‑resizable, GPU‑uploaded) buffer. +`Column` and the leaf position buffer scale linearly — no problem here. + +### 2.4 Layout settle — `layout/force-simulation.bench.ts` + +Total time to build **and settle** a layout (what the user waits on, streamed +across frames in production). Fixed 6 iterations per row. + +| flat‑force (cola `Descent`) | 50 | 120 | 200 | +| --------------------------- | ------- | ------ | ------ | +| build + settle | 29.8 ms | 199 ms | 409 ms | + +| community‑force (Louvain + sparse‑stress seed + FA2) | 500 | 1,500 | 3,000 | +| ---------------------------------------------------- | ------ | -------- | -------- | +| build only (Louvain + seed alloc + matrices) | 2.1 ms | 6.2 ms | 12.2 ms | +| build + settle | 522 ms | 3,466 ms | 4,391 ms | + +Two takeaways: cola’s O(N²) `Descent` reaches ~0.4 s at 200 nodes, which is +exactly why the flat‑force tier is capped at `flatLayoutMaxNodes: 200`. And the +FA2 **settle dominates build by 250–560×** — construction is negligible; the +iteration loop is the cost. + +### 2.5 End‑to‑end commit / rebuild — `core/commit-rebuild.bench.ts` + +Driven through the real `GraphWorker` exactly as `entry.ts` does. + +| No‑op re‑commit (`commitStructure()` with nothing changed) | mean | +| ---------------------------------------------------------- | ----------- | +| flat‑force (150 nodes) | 0.125 ms | +| community‑force (1,500 nodes) | **8.14 ms** | +| hierarchical‑lod (8,000 nodes) | 2.91 ms | + +| Ingest 2,000 nodes / 5,000 links | mean | +| ------------------------------------------- | -------------------- | +| bulk: 1 batch + 1 commit | 46.7 ms | +| streaming: 100‑entity batches + commit each | **1,018 ms (21.8×)** | + +The community‑force no‑op (8 ms) is _more_ expensive than the hierarchical no‑op +(2.9 ms) despite fewer nodes: the flat tiers touch every node and every link on +each commit, while the hierarchical tier only recomputes the cut and the visible +clusters. This is the clearest evidence of the missing incremental commit path. + +### 2.6 Edge aggregation keying — `geometry/edge-aggregation.bench.ts` + +| `makePairKey` over N pairs | 500 | 2,000 | 8,000 | +| -------------------------- | -------- | -------- | -------- | +| classify every pair | 0.012 ms | 0.047 ms | 0.185 ms | + +At ~23 ns/pair and realistic cluster‑pair counts, **`makePairKey` is not a +bottleneck** — this bench exists to rule it out, and it does. + +--- + +## 3. Inefficiencies found + +Ordered by impact. Severity: **HIGH** = shows up in interaction latency at +realistic sizes; **MEDIUM** = meaningful at large N or high frame rates; +**LOW** = measurable but small. + +### F1 — No no‑op / incremental fast path in the flat & community commit — **HIGH** + +`core/graph-worker.ts:1071` `#commitFlat` runs the following **unconditionally on +every commit**, regardless of whether anything changed: + +- `#allNodeEntityIdxs()` (`:1175`) collects every node index across all type‑set + groups and `result.sort(...)` — **O(N log N) every commit** (`:1182`). +- Topology detection (`:1093`–`:1107`) builds `new Set(entityIdxs)` and + `new Set(existing.nodeIds)`, then two `.some(...)` scans with per‑element + `String(idx)` / `Number(id)` conversions — **O(N) allocations + scans every + commit**. +- `#writeFlatStyle(...)` (`:1126` → `:1377`) loops all layout nodes and writes + radius/colour/entityIdx for each — **O(N) every commit**. The comment at + `:1123`–`:1125` states the intent ("so colours track a type change even when + the layout was not rebuilt"), but the cost is paid even on a true no‑op. +- `#buildFlatRenderEdges(...)` (`:1127` → `:1402`) rebuilds the entire render‑edge + list, calling `this.#links.linksForEntity(...)` per node (`:1411`) — **O(N+E) + plus per‑node array allocation every commit** (compounds with F4). + +**Evidence:** no‑op re‑commit = **8.14 ms at 1,500 nodes** (§2.5). None of this +work is needed when the graph is unchanged, and only the style write is needed +for a type/highlight‑only change. + +### F2 — Streaming ingest re‑does whole‑graph work per batch (superlinear) — **HIGH** + +`entry.ts` calls `worker.ingestBatch(...)` then `worker.commitStructure({deltas})` +per incoming batch. Each commit pays F1’s full O(N log N)+O(N)+O(N+E), and in +community‑force each batch also drives FA2 `absorb` (`layout/community-layout.ts:359`), +which rebuilds the **entire** node+edge matrix in `#rebuildMatrices` (`:251`), +re‑derives FA2 settings, conditionally re‑runs Louvain, and resets the settle +detector. `resolveEdges` (`:664`) rebuilds a `Map` keyed by a `` `${lo}:${hi}` `` +string for every edge on every rebuild. + +**Evidence:** the same final graph costs **46.7 ms bulk vs 1,018 ms streamed +(21.8×)** (§2.5). For K batches over N nodes this is ≈ O(K·N) instead of O(N). + +### F3 — `boundedLabelPropagation` allocates per node per iteration — **MEDIUM** + +`hierarchy/community.ts:63` allocates `const scores = new Map()` +**inside the per‑node loop**, run for up to 20 iterations (`:57`), and +`deterministicShuffle` (`:23`) copies the component array (`[...indices]`) once +per iteration. `csr-graph.ts` `buildInducedCsr` also builds an array‑of‑arrays +adjacency with a `{ neighbor, weight }` object per half‑edge (`:31`, `:51`–`:52`) +— 2·E temporary objects — before flattening to typed arrays. + +**Evidence:** label propagation is **111 ms for a 20k component** and dominates +the pipeline (§2.2). For a giant component this is hundreds of thousands of Map +allocations. + +### F4 — `linksForEntity` allocates a fresh array + objects on every call — **MEDIUM** + +`stores/link-store.ts:134` `linksForEntity` returns a brand‑new `LinkEndpoint[]` +with a fresh object literal per incident link on **every call**. Hot callers: + +- `#buildFlatRenderEdges` (`graph-worker.ts:1411`) — per node, every commit (F1). +- `#seedFlatNodes`, `#buildEntityFanOut` in the worker. +- `community.ts` `topDegreeEntity` (`:168`), `collectLinkFeatures` (`:186`), + `linkSignatureKey` (`:279`) — several of these only need the _degree_ + (`.length`) yet allocate the full endpoint array. + +**Evidence:** part of the per‑commit O(N+E) cost and the community keying cost; +degree‑only callers pay a full allocation for a number. + +### F5 — Double type‑set keying per node during ingest — **MEDIUM** + +For every non‑link entity, `ingestBatch` calls `#peekGroup(entity)` (`graph-worker.ts:662` +→ `:698`) _and then_ `insertNodeEntity(entity)` (`:670` → `:596`). Both construct +a `new ReadonlySortedSet(...)` (`readonly-sorted-set.ts:40` does +`[...new Set(values)].sort(...)`) and both call `TypeSetStore.getOrCreate`, which +builds the key with `directTypeIdxs.items.join(",")` (`type-set-store.ts:112`). +The peek exists only to snapshot the group’s `count` before insert for delta +computation, but it fully constructs (and, via `getOrCreate`, creates) the group. + +**Evidence:** peek+insert is a stable **~1.9×** the single‑keying cost (§2.1). + +### F6 — Per‑frame allocation in `#emitPositions` — **MEDIUM** (code review; not isolated‑benchmarked) + +`core/graph-worker.ts:1837` `#emitPositions` runs on **every** position tick while +a layout settles and re‑derives structure that only changes on a cut change: + +- `#computePorts()` (`:1842`) recomputes ports each tick. +- The obstacle list `this.#rendered.map(...)` (`:1852`) allocates a fresh array of + objects each tick (hierarchical). +- All bezier segments are rebuilt from scratch each tick (`:1856` / `:1868`). +- `new Float32Array(this.#rendered.length * 2)` (`:1872`) is allocated each tick. +- `#buildEntityFanOut(...)` (`:1883`) runs each tick when a cut is present. + +Some of this is inherent to the transfer model (the positions frame’s buffers are +_transferred_ to the main thread, so they cannot be reused). But ports, fan‑out +_topology_, and the obstacle set only change when the **cut** changes, not when +positions move — the code recomputes them every tick anyway. This was not +isolated into its own bench (it is tightly coupled to a live cut + cluster tree); +it is exercised once per commit inside `core/commit-rebuild.bench.ts` and flagged +here from code review. A targeted bench should follow the fix. + +### F7 — cola flat layout is O(N²) — **INFO** (validates the 200‑node cap) + +`layout/flat-layout.ts` builds a full N×N distance matrix +(`Calculator(...).DistanceMatrix()` + `Descent.createSquareMatrix`, `:163`–`:173`) +and runs `Descent.rungeKutta()` (O(N²)/step). Measured 0.41 s at 200 nodes (§2.4). +This is expected and correctly gated by `flatLayoutMaxNodes: 200`; **no action** +beyond keeping the cap. + +### F8 — `makeGrowableBuffer(resizable:false)` ignores `maxByteLength` — **LOW** + +`buffers/growable-buffer.ts:40` returns a _fixed_ `SharedArrayBuffer` when +`resizable === false`, so `FlatGraphBuffer` can never grow in place — every +growth re‑allocates and memcpies. `ensureCapacity`’s "double the ceiling" +comment (`:158`) only affects the ignored `maxByteLength` argument for such +buffers. This is currently fine because `flatCapacityFor` (`graph-worker.ts:157`) +adds 1.5× slack so growth is amortized O(N) (§2.3), but the comment is misleading +and the amortization silently depends on the caller always over‑allocating. + +--- + +## 4. Recommended fixes (ordered by impact / effort) + +1. **Add a no‑op / incremental fast path to `#commitFlat` (F1, F2).** _High impact, + medium effort._ The worker already computes `deltas` in `ingestBatch`; thread a + "structure actually changed" signal through so a commit with empty deltas and + no mode change skips `#writeFlatStyle` + `#buildFlatRenderEdges` (or only + re‑emits positions). Cache the sorted `#allNodeEntityIdxs()` result and + invalidate it on ingest instead of re‑sorting every commit. Replace the + dual‑`Set` topology scan (`:1093`–`:1107`) with a monotonically‑increasing + structure version + node‑count compare. Separate a cheap "restyle only" path + (type/highlight change) from the full topology rebuild. + +2. **Coalesce commits during a streaming burst (F2).** _High impact, low effort._ + The worker already debounces the trailing Louvain (`#scheduleFlatLouvainLinger`, + `FLAT_LOUVAIN_LINGER_MS`). Apply the same idea to structure commits: during an + ingest burst, coalesce multiple `INGEST_BATCH`es into a single + `commitStructure` on a microtask/rAF boundary. Combined with fix 1 this + collapses the 21.8× streaming penalty toward the bulk cost. + +3. **Make `boundedLabelPropagation` and `buildInducedCsr` allocation‑free in the + inner loop (F3).** _Medium impact, medium effort._ Reuse one scores buffer + (e.g. a `Float64Array` indexed by label plus a "touched labels" list to reset + in O(touched)), and shuffle in place (Fisher–Yates on a persistent array) + instead of `[...indices]` per iteration. In `buildInducedCsr`, count degrees + first then fill parallel `Int32Array`/`Float32Array` neighbour/weight arrays, + dropping the 2·E `{neighbor, weight}` object churn. + +4. **Give `LinkStore` a non‑allocating degree + iteration API (F4).** _Medium + impact, low effort._ Add `degree(entityIdx): number` (return the adjacency + list length) and either `forEachLink(entityIdx, cb)` or a read‑only accessor + for the adjacency `LinkIdx[]`. Point the degree‑only callers + (`topDegreeEntity`, seeding) and the hot per‑commit `#buildFlatRenderEdges` + scan at these to avoid per‑node array + object allocation. + +5. **Key each ingested node’s type‑set once (F5).** _Low–medium impact, low + effort._ Compute the `ReadonlySortedSet` + `TypeSetKey` a single time per + entity and reuse it for both the pre‑insert snapshot and the insert (e.g. have + `insertNodeEntity` return the group + whether it was newly created, or pass the + pre‑built group into it). Removes one `ReadonlySortedSet` construction and one + `join(",")` per node. + +6. **Cache cut‑derived structure across position ticks (F6).** _Medium impact, + medium effort._ Recompute ports, fan‑out topology, and the obstacle list only + when the cut/structure version changes; on a pure position tick, refresh the + coordinates into reused scratch and only allocate the transferred frame + buffers. Add a bench once decoupled. + +7. **Fix the misleading non‑resizable growth comment / consider real geometric + headroom in `ensureCapacity` (F8).** _Low impact, low effort._ Either document + that non‑resizable buffers ignore `maxByteLength` and rely on the caller’s + over‑allocation, or have `ensureCapacity` itself apply geometric slack so + amortization doesn’t depend on every caller remembering to. + +--- + +## 5. Tests to add + +Regression + invariant tests that lock in the wins above. Suggested locations in +parentheses. + +- **No‑op commit does no structural work** (`core/graph-worker.test.ts`). After a + settled commit, call `commitStructure()` with no deltas and assert it does not + rebuild the flat layout or re‑emit a structure frame with a new count. Concrete + hooks: spy on `onStructureFrame`/`onLayoutMessage` and assert no + `LAYOUT_CREATED`/`LAYOUT_DESTROYED`; assert the structure version is unchanged. + This is the direct guard for F1 and the 8 ms no‑op. + +- **Streaming equals bulk in structural output** (`core/graph-worker.test.ts`). + Ingesting a graph as one batch vs many batches must produce the same final + node/link counts, type‑set groups, and cluster tree. Once fix 1/2 land, extend + it to assert the number of flat‑layout rebuilds is bounded (e.g. does not grow + linearly with the batch count). + +- **Buffer growth stays geometric / no per‑append realloc** + (`buffers/position-buffer.test.ts`, `buffers/growable-buffer.test.ts`). Append + many records into a small `FlatGraphBuffer` via `flatCapacityFor` and assert the + number of `republish` callbacks is O(log N), not O(N) — this guards the 18× + cliff (§2.3, F8). Assert `capacity >= count` headroom holds after each grow. + +- **`linksForEntity` allocation callers use degree** (`stores/link-store.test.ts` + - call‑site tests). Add `degree()` and assert it equals + `linksForEntity().length` for representative graphs, so the non‑allocating path + can’t silently diverge (F4). + +- **Type‑set is keyed once per ingested node** (`core/graph-worker.test.ts` or a + `TypeSetStore` spy). Assert `getOrCreate` (or `ReadonlySortedSet` construction) + is called once per new node entity, not twice, after fix 5 (F5). + +- **Community detection determinism + allocation‑free inner loop** + (`hierarchy/community.test.ts`). Keep the existing determinism assertions; after + fix 3, add a test that `boundedLabelPropagation` produces identical labels to + the current implementation on fixed inputs (a refactor‑safety snapshot). + +- **Invariant: flat commit emits exactly the loaded node set** + (`core/graph-worker.test.ts`). Assert `#allNodeEntityIdxs()`’s output (via the + emitted flat frame count) equals the number of ingested non‑link entities, so a + future caching optimization of that method can’t drop or duplicate nodes. + +--- + +## 6. Benchmark files added & how to run + +All under `apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/`: + +| File | Covers | +| ------------------------------------ | ----------------------------------------------------------------------------------- | +| `bench-fixtures.ts` | Shared deterministic graph builders (not a bench itself). | +| `stores/ingestion.bench.ts` | Type‑set keying, entity interning, link adjacency. | +| `hierarchy/community.bench.ts` | `buildInducedCsr`, `connectedComponents`, `boundedLabelPropagation`, full pipeline. | +| `buffers/growable-buffer.bench.ts` | Flat/leaf buffer writes, presized vs geometric vs naïve growth, `Column` push. | +| `layout/force-simulation.bench.ts` | flat‑force (cola) and community‑force (FA2) build + settle. | +| `core/commit-rebuild.bench.ts` | No‑op re‑commit + bulk vs streaming ingest through the real `GraphWorker`. | +| `geometry/edge-aggregation.bench.ts` | `makePairKey` (rules it out as a hot path). | + +Run one file: + +```bash +cd apps/hash-frontend +../../node_modules/.bin/vitest bench --run \ + src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts +``` + +Run them all: + +```bash +cd apps/hash-frontend +../../node_modules/.bin/vitest bench --run \ + src/pages/shared/graph-visualizer-2/worker +``` + +Note: `layout/force-simulation.bench.ts` and `core/commit-rebuild.bench.ts` build +and settle real layouts / workers and take ~1–1.5 min each; the store, community, +buffer, and geometry benches finish in seconds. + +### Status of the new files + +- **ESLint:** clean (`eslint --report-unused-disable-directives`, exit 0) for all + seven files. +- **TypeScript:** clean — `tsc --noEmit` reports **no** errors in the added files. + (The app has ~34 pre‑existing `tsc` errors in unrelated files — `supply-chain/*`, + `slide-stack`, `math/hash.ts`, missing `recharts`/`@tanstack/react-table` — none + touched here.) +- No production code was changed; the only additions are the benchmark files and + `bench-fixtures.ts`. Pre‑existing local edits in `entry.ts`, `protocol.ts`, + `random.ts`, `random.test.ts` were left untouched. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts new file mode 100644 index 00000000000..02448fc3ae8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts @@ -0,0 +1,212 @@ +/** Deterministic synthetic-graph builders for benchmarks. */ +import { entityIdFromComponents } from "@blockprotocol/type-system"; + +import { EntityIdx, TypeSetIdx } from "../ids"; +import { Column } from "./collections/column"; +import { radiusForDegree } from "./entity-style"; +import { mulberry32 } from "./random"; +import { LinkStore } from "./stores/link-store"; + +import type { ForceEdge, ForceNode } from "./layout/force-simulation"; +import type { IngestEntity, TypeSchemaEntry } from "./protocol"; +import type { + EntityId, + EntityUuid, + LinkData, + VersionedUrl, + WebId, +} from "@blockprotocol/type-system"; + +const WEB_ID = "11111111-1111-4111-8111-111111111111" as WebId; + +/** EntityId for the given index. Node and link ids share the index space. */ +export function benchEntityId(index: number): EntityId { + const uuid = + `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid; + return entityIdFromComponents(WEB_ID, uuid); +} + +export function benchNodeTypeUrl(typeIndex: number): VersionedUrl { + return `https://example.com/types/entity-type/bench-node-${typeIndex}/v/1` as VersionedUrl; +} + +export const BENCH_LINK_TYPE_URL = + "https://example.com/types/entity-type/bench-link/v/1" as VersionedUrl; + +/** Knobs shaping a synthetic graph. `linkCount` links point mostly at the first `hubCount` nodes. */ +export interface GraphShape { + readonly nodeCount: number; + readonly linkCount: number; + /** Distinct node entity types (each node is assigned one). */ + readonly typeCount: number; + /** High-degree hubs that most links point into (skews the degree distribution, like real data). */ + readonly hubCount: number; + /** Fraction [0,1] of nodes that are query roots; the rest are frontier nodes. */ + readonly rootFraction: number; + readonly seed: number; +} + +/** Type schemas for `typeCount` flat (parentless) node types plus the one link type. */ +export function benchTypeSchemas(typeCount: number): TypeSchemaEntry[] { + const schemas: TypeSchemaEntry[] = []; + for (let typeIndex = 0; typeIndex < typeCount; typeIndex++) { + schemas.push({ + url: benchNodeTypeUrl(typeIndex), + title: `Bench Node ${typeIndex}`, + allOfRefs: [], + }); + } + schemas.push({ + url: BENCH_LINK_TYPE_URL, + title: "Bench Link", + inverseTitle: "Bench Link Of", + allOfRefs: [], + }); + return schemas; +} + +/** Pick a link target with a hub bias: ~70% of links point at one of the first `hubCount` nodes. */ +function pickTarget( + random: () => number, + nodeCount: number, + hubCount: number, +): number { + if (hubCount > 0 && random() < 0.7) { + return Math.floor(random() * hubCount) % nodeCount; + } + return Math.floor(random() * nodeCount); +} + +/** + * `nodeCount` node entities followed by `linkCount` link entities + * with hub-biased endpoints. + */ +export function buildIngestEntities(shape: GraphShape): IngestEntity[] { + const { nodeCount, linkCount, typeCount, hubCount, rootFraction, seed } = + shape; + const random = mulberry32(seed); + const entities: IngestEntity[] = []; + const rootCutoff = Math.round(nodeCount * rootFraction); + + for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) { + const typeIndex = Math.floor(random() * Math.max(1, typeCount)); + entities.push({ + entityId: benchEntityId(nodeIndex), + entityTypeIds: [benchNodeTypeUrl(typeIndex)], + isLink: false, + isRoot: nodeIndex < rootCutoff, + }); + } + + for (let linkIndex = 0; linkIndex < linkCount; linkIndex++) { + const left = Math.floor(random() * nodeCount); + let right = pickTarget(random, nodeCount, hubCount); + if (right === left) { + right = (right + 1) % nodeCount; + } + const linkData = { + leftEntityId: benchEntityId(left), + rightEntityId: benchEntityId(right), + } as LinkData; + entities.push({ + entityId: benchEntityId(nodeCount + linkIndex), + entityTypeIds: [BENCH_LINK_TYPE_URL], + isLink: true, + isRoot: false, + linkData, + }); + } + + return entities; +} + +/** Undirected edge index pairs (local indices `[0, nodeCount)`), hub-biased, deduplicated. */ +function buildEdgePairs(shape: GraphShape): [number, number][] { + const { nodeCount, linkCount, hubCount, seed } = shape; + // Offset the seed so edge pairs draw a different stream than buildIngestEntities. + const random = mulberry32(seed + 0x9e3779b9); + const seen = new Set(); + const pairs: [number, number][] = []; + for (let linkIndex = 0; linkIndex < linkCount; linkIndex++) { + const left = Math.floor(random() * nodeCount); + let right = pickTarget(random, nodeCount, hubCount); + if (right === left) { + right = (right + 1) % nodeCount; + } + const lo = Math.min(left, right); + const hi = Math.max(left, right); + const key = lo * nodeCount + hi; + if (seen.has(key)) { + continue; + } + seen.add(key); + pairs.push([lo, hi]); + } + return pairs; +} + +/** + * Force-layout graph. Radii scale with degree, start positions are spread + * via golden-angle so nodes are not stacked on the origin. + */ +export function buildForceGraph(shape: GraphShape): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} { + const pairs = buildEdgePairs(shape); + const degree = new Int32Array(shape.nodeCount); + for (const [left, right] of pairs) { + degree[left]! += 1; + degree[right]! += 1; + } + + const goldenAngle = Math.PI * (3 - Math.sqrt(5)); + const nodes: ForceNode[] = []; + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + const distance = 20 * Math.sqrt(nodeIndex + 1); + const angle = nodeIndex * goldenAngle; + nodes.push({ + id: String(nodeIndex), + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: radiusForDegree(degree[nodeIndex]!), + }); + } + + const edges: ForceEdge[] = pairs.map(([left, right]) => ({ + source: String(left), + target: String(right), + weight: 1, + })); + + return { nodes, edges }; +} + +/** Link store and entity index column. Entity indices are the contiguous range `[0, nodeCount)`. */ +export function buildCommunityInputs(shape: GraphShape): { + readonly entityIdxs: Column; + readonly links: LinkStore; +} { + const entityIdxs = new Column( + Int32Array, + Math.max(1, shape.nodeCount), + ); + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + entityIdxs.push(EntityIdx(nodeIndex)); + } + + const links = new LinkStore(); + const pairs = buildEdgePairs(shape); + const linkTypeIdx = TypeSetIdx(0); + for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) { + const [left, right] = pairs[pairIndex]!; + links.insert( + EntityIdx(left), + EntityIdx(right), + linkTypeIdx, + EntityIdx(shape.nodeCount + pairIndex), + ); + } + + return { entityIdxs, links }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.bench.ts new file mode 100644 index 00000000000..fff67f44357 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.bench.ts @@ -0,0 +1,101 @@ +/** + * Shared-buffer write + growth costs. Two things matter here: the per-commit + * cost of writing an entire flat-tier buffer (position + radius + colour + + * entityIdx for every node, which `#writeFlatStyle` / `#buildFlatRenderEdges` + * pay on every commit) and the cost of GROWING a GPU buffer, which is always a + * re-allocate + full memcpy because `FlatGraphBuffer` is non-resizable. + * + * The three flat-buffer cases quantify that growth: `presized` is the floor, + * `geometric` mirrors production (`flatCapacityFor` in core/graph-worker.ts + * over-allocates 1.5x so growth is amortized), and `fixed-step` is the naive + * grow-to-exact-count path production deliberately avoids -- the gap between the + * last two is the payoff of the 1.5x slack, and the reason a test should lock it in. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { Column } from "../collections/column"; +import { EntityPositionBuffer, FlatGraphBuffer } from "./position-buffer"; + +import type { RepublishHandler } from "./growable-buffer"; + +const SIZES: readonly number[] = [1_000, 10_000, 50_000]; + +const RGBA: readonly [number, number, number, number] = [10, 20, 30, 255]; + +const noopRepublish: RepublishHandler = () => { + // The benchmark discards the re-published buffer; only the copy cost matters. +}; + +/** Write a full set of `count` records (the shape of a flat-tier commit). */ +function writeAllRecords(buffer: FlatGraphBuffer, count: number): void { + for (let index = 0; index < count; index++) { + buffer.setPosition(index, index * 1.5, index * 0.5); + buffer.setRadius(index, 4); + buffer.setColor(index, RGBA); + buffer.setEntityIdx(index, index); + } + buffer.setCount(count); + buffer.commit(); +} + +/** Mirrors `flatCapacityFor` in core/graph-worker.ts (1.5x geometric slack). */ +function flatCapacityFor(count: number): number { + return Math.max(count + 64, Math.ceil(count * 1.5)); +} + +for (const size of SIZES) { + describe(`flat buffer (${size} nodes)`, () => { + // Buffer sized up front: the steady-state per-commit write, no growth. + bench("presized: write all records + commit", () => { + const buffer = new FlatGraphBuffer(size, noopRepublish); + writeAllRecords(buffer, size); + }); + + // Production path: grow with 1.5x geometric slack, so the number of + // re-allocations is O(log N) and total copied bytes stay O(N). + bench("geometric growth (1.5x, production path)", () => { + const buffer = new FlatGraphBuffer(1_024, noopRepublish); + let capacity = 1_024; + while (capacity < size) { + capacity = flatCapacityFor(capacity); + buffer.ensureCapacity(capacity); + } + writeAllRecords(buffer, size); + }); + + // Naive path production avoids: grow to the exact live count in 1024-record + // steps, so every step re-allocates + memcpies the whole buffer -> O(N^2). + bench("fixed-step growth (1024, naive)", () => { + const buffer = new FlatGraphBuffer(1_024, noopRepublish); + for (let filled = 1_024; filled < size; filled += 1_024) { + buffer.ensureCapacity(Math.min(size, filled + 1_024)); + } + writeAllRecords(buffer, size); + }); + }); + + describe(`leaf position buffer (${size} nodes)`, () => { + // Per-tick position write for a settling leaf layout: this runs every frame. + bench("setPosition x N + commit", () => { + const buffer = new EntityPositionBuffer(size); + for (let index = 0; index < size; index++) { + buffer.setPosition(index, index * 1.5, index * 0.5); + } + buffer.commit(); + }); + }); + + describe(`column growth (${size} pushes)`, () => { + // Geometric-growth push path (entity/link columns, adjacency-free). + bench("Column.push from 1024", () => { + const column = new Column(Float32Array, 1_024); + for (let index = 0; index < size; index++) { + column.push(index * 0.25); + } + }); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts new file mode 100644 index 00000000000..7a1581eda3b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts @@ -0,0 +1,162 @@ +/** + * End-to-end rebuild/redraw path: what `commitStructure` costs, driven through + * the real {@link GraphWorker} exactly as `entry.ts` does (ingest -> commit). + * + * Two questions: + * - `no-op re-commit`: how much work does a commit do when NOTHING changed? + * (`recomputeMode` + `#allNodeEntityIdxs` sort + topology rescan + `#writeFlatStyle` + * + `#buildFlatRenderEdges`, or the hierarchical cut recompute + frame rebuild.) + * A no-op should be near-free; anything else is per-commit waste on every batch. + * - `bulk vs streaming`: the same final graph delivered as one batch+commit vs. + * many batch+commits. The gap is the per-commit O(N) work (re)done per batch -- + * the streaming-ingest tax. + * + * The GraphWorker runs its layout scheduler on MessageChannels; a synchronous + * bench body never lets those macrotasks fire mid-measurement, and vitest tears + * the process down after the run, so no scheduler cleanup is needed here. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { benchTypeSchemas, buildIngestEntities } from "../bench-fixtures"; +import { GraphWorker } from "./graph-worker"; + +import type { GraphShape } from "../bench-fixtures"; +import type { IngestEntity } from "../protocol"; + +/** Fresh GraphWorkers are built per iteration below, and each is relatively + * expensive, so bound the fresh-worker benches to a fixed iteration count. */ +const FRESH_WORKER_OPTS = { + time: 0, + iterations: 6, + warmupTime: 0, + warmupIterations: 1, +} as const; + +const IGNORE = (): void => { + // Benchmarks discard the worker's output frames / layout messages. +}; + +function newWorker(typeCount: number): GraphWorker { + const worker = new GraphWorker(defaultVizConfig); + worker.onLayoutMessage = IGNORE; + worker.onStructureFrame = IGNORE; + worker.onPositionsFrame = IGNORE; + worker.registerTypes(benchTypeSchemas(typeCount), []); + return worker; +} + +/** Ingest the whole graph, commit twice (stabilise mode + tree), record a + * viewport (so the hierarchical tier has a cut to recompute), and return the + * worker sitting in a committed steady state. */ +function primedWorker(shape: GraphShape): GraphWorker { + const worker = newWorker(shape.typeCount); + const deltas = worker.ingestBatch(buildIngestEntities(shape)); + worker.commitStructure({ deltas }); + worker.commitStructure(); + worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + return worker; +} + +interface Case { + readonly label: string; + readonly shape: GraphShape; +} + +const NOOP_CASES: readonly Case[] = [ + { + label: "flat-force (150 nodes)", + shape: { + nodeCount: 150, + linkCount: 300, + typeCount: 8, + hubCount: 8, + rootFraction: 1, + seed: 41, + }, + }, + { + label: "community-force (1500 nodes)", + shape: { + nodeCount: 1_500, + linkCount: 4_000, + typeCount: 16, + hubCount: 40, + rootFraction: 1, + seed: 42, + }, + }, + { + label: "hierarchical-lod (8000 nodes)", + shape: { + nodeCount: 8_000, + linkCount: 20_000, + typeCount: 24, + hubCount: 80, + rootFraction: 1, + seed: 43, + }, + }, +]; + +for (const { label, shape } of NOOP_CASES) { + const worker = primedWorker(shape); + describe(`no-op re-commit: ${label}`, () => { + bench("commitStructure() with no changes", () => { + worker.commitStructure(); + }); + }); +} + +// Same final graph, delivered bulk vs streamed. Lands in community-force. +const STREAM_SHAPE: GraphShape = { + nodeCount: 2_000, + linkCount: 5_000, + typeCount: 16, + hubCount: 40, + rootFraction: 1, + seed: 51, +}; +const STREAM_ENTITIES: readonly IngestEntity[] = + buildIngestEntities(STREAM_SHAPE); +const STREAM_BATCH = 100; + +describe(`ingest ${STREAM_SHAPE.nodeCount} nodes / ${STREAM_SHAPE.linkCount} links`, () => { + bench( + "bulk: 1 batch + 1 commit", + () => { + const worker = newWorker(STREAM_SHAPE.typeCount); + const deltas = worker.ingestBatch(STREAM_ENTITIES); + worker.commitStructure({ deltas }); + }, + FRESH_WORKER_OPTS, + ); + + bench( + `streaming: ${STREAM_BATCH}-entity batches + commit each`, + () => { + const worker = newWorker(STREAM_SHAPE.typeCount); + for ( + let start = 0; + start < STREAM_ENTITIES.length; + start += STREAM_BATCH + ) { + const chunk = STREAM_ENTITIES.slice(start, start + STREAM_BATCH); + const deltas = worker.ingestBatch(chunk); + worker.commitStructure({ deltas }); + worker.restyleIfRootsFlipped(); + } + }, + FRESH_WORKER_OPTS, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts index 73e988cc913..b7ff57e55ab 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts @@ -1,8 +1,7 @@ -/** - * Compressed sparse row (CSR) graph over a set of entities, plus the operations the - * community sub-clustering needs (build from the link store, connected components). Pure: - * it operates on the data passed in, holding no worker state. - */ +/** Compressed sparse row (CSR) graph over a set of entities. */ + +import { BitSet } from "./collections/bitset"; + import type { EntityIdx } from "../ids"; import type { Column } from "./collections/column"; import type { LinkStore } from "./stores/link-store"; @@ -16,8 +15,9 @@ export interface CsrGraph { } /** - * Build a compressed sparse row graph from the link store, restricted to the given entity - * set (links with an endpoint outside the set, or a self-loop, are dropped). + * Build a CSR graph restricted to the given entity set. + * + * Links with an endpoint outside the set, or self-loops, are dropped. */ export function buildInducedCsr( entityIdxs: Column, @@ -28,9 +28,10 @@ export function buildInducedCsr( localIndex.set(entityIdxs.get(idx), idx); } - const adjacency: { neighbor: number; weight: number }[][] = [ - ...entityIdxs, - ].map(() => []); + const adjacency: number[][] = Array.from( + { length: entityIdxs.length }, + () => [], + ); for (let linkIdx = 0; linkIdx < links.count; linkIdx++) { const left = links.getLeft(linkIdx); @@ -48,22 +49,22 @@ export function buildInducedCsr( continue; } - adjacency[leftLocal]!.push({ neighbor: rightLocal, weight: 1 }); - adjacency[rightLocal]!.push({ neighbor: leftLocal, weight: 1 }); + adjacency[leftLocal]!.push(rightLocal); + adjacency[rightLocal]!.push(leftLocal); } const totalEdges = adjacency.reduce((sum, adj) => sum + adj.length, 0); const offsets = new Int32Array(entityIdxs.length + 1); const neighbors = new Int32Array(totalEdges); const weights = new Float32Array(totalEdges); + weights.fill(1); let offset = 0; for (let idx = 0; idx < adjacency.length; idx++) { offsets[idx] = offset; - for (const edge of adjacency[idx]!) { - neighbors[offset] = edge.neighbor; - weights[offset] = edge.weight; - offset++; + for (const neighbor of adjacency[idx]!) { + neighbors[offset] = neighbor; + offset += 1; } } offsets[entityIdxs.length] = offset; @@ -74,21 +75,21 @@ export function buildInducedCsr( /** Connected components of the graph, each a list of local node indices. */ export function connectedComponents(graph: CsrGraph): number[][] { const nodeCount = graph.nodeIds.length; - const visited = new Uint8Array(nodeCount); + const visited = BitSet.empty(nodeCount); const components: number[][] = []; - const queue: number[] = []; + const stack: number[] = []; for (let start = 0; start < nodeCount; start++) { - if (visited[start]) { + if (visited.has(start)) { continue; } const component: number[] = []; - queue.push(start); - visited[start] = 1; + stack.push(start); + visited.add(start); - while (queue.length > 0) { - const node = queue.pop()!; + while (stack.length > 0) { + const node = stack.pop()!; component.push(node); for ( @@ -97,9 +98,9 @@ export function connectedComponents(graph: CsrGraph): number[][] { edge++ ) { const neighbor = graph.neighbors[edge]!; - if (!visited[neighbor]) { - visited[neighbor] = 1; - queue.push(neighbor); + if (!visited.has(neighbor)) { + visited.add(neighbor); + stack.push(neighbor); } } } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts index 71acb83a239..325cae86fd2 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.test.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; import { entityIdFromComponents } from "@blockprotocol/type-system"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts index 0da663eaae5..112576a49f3 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-id-codec.ts @@ -1,10 +1,12 @@ +/* eslint-disable no-bitwise, no-param-reassign */ /** - * Thread-agnostic codec for the EntityIdx -> EntityId join map's byte layout. The worker - * (buffers/entity-id-buffer.ts) writes it; the main thread reads it on demand. One module - * owns the packing so both sides agree on the layout: per record, - * `webId (16) | entityUuid (16) | draftId (16)`, each UUID packed to 16 bytes, with the - * draftId slot all-zero when the entity is not a draft. + * Codec for the EntityIdx to EntityId shared buffer. + * + * Per-record layout: `webId (16) | entityUuid (16) | draftId (16)`, + * each UUID packed to 16 bytes. The draftId slot is all-zero when the + * entity is not a draft. */ + import { type DraftId, type EntityId, @@ -14,38 +16,55 @@ import { splitEntityId, } from "@blockprotocol/type-system"; -/** Bytes per UUID (128 bits). */ const UUID_BYTES = 16; /** Bytes per record: webId + entityUuid + draftId. */ export const ENTITY_ID_BYTES = UUID_BYTES * 3; -/** `version: int32`; also the byte offset where the records begin, so a reader builds a - * records-region view with `new Uint8Array(raw, ID_HEADER_BYTES)`. */ +/** Byte offset where records begin (preceded by an int32 version counter). */ export const ID_HEADER_BYTES = 4; -/** The draftId slot when the entity is not a draft. */ -const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; -/** Pack a hyphenated UUID string into 16 bytes at `offset` (in place, zero-alloc). */ +/** Char code to 4-bit nibble value. Valid for '0'-'9', 'A'-'F', 'a'-'f'. */ +// prettier-ignore +const HEX_VAL = new Uint8Array([ +// 0 1 2 3 4 5 6 7 8 9 A B C D E F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x00 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x10 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x20 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, // 0x30 '0'-'9' + 0,10,11,12,13,14,15, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x40 'A'-'F' + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x50 + 0,10,11,12,13,14,15, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x60 'a'-'f' + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x70 +]); + +/** Byte value to two-char hex string. */ +const BYTE_HEX: readonly string[] = Array.from({ length: 256 }, (_, i) => + i.toString(16).padStart(2, "0"), +); + +const HYPHEN = 0x2d; + +/** Pack a hyphenated UUID string into 16 bytes at `offset` in place. */ function writeUuid(target: Uint8Array, offset: number, uuid: string): void { - const hex = uuid.replace(/-/g, ""); + let index = 0; for (let i = 0; i < UUID_BYTES; i++) { - // Writing into the caller's buffer is the point; a fresh array per UUID would be a - // perf nightmare. - // eslint-disable-next-line no-param-reassign - target[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + if (uuid.charCodeAt(index) === HYPHEN) { + index += 1; + } + + target[offset + i] = + (HEX_VAL[uuid.charCodeAt(index)]! << 4) | + HEX_VAL[uuid.charCodeAt(index + 1)]!; + index += 2; } } /** Reconstruct a hyphenated UUID string from 16 bytes at `offset`. */ function readUuid(source: Uint8Array, offset: number): string { - let hex = ""; - for (let i = 0; i < UUID_BYTES; i++) { - hex += source[offset + i]!.toString(16).padStart(2, "0"); - } - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + const b = (i: number): string => BYTE_HEX[source[offset + i]!]!; + return `${b(0)}${b(1)}${b(2)}${b(3)}-${b(4)}${b(5)}-${b(6)}${b(7)}-${b(8)}${b(9)}-${b(10)}${b(11)}${b(12)}${b(13)}${b(14)}${b(15)}`; } -/** Write the EntityId for `entityIdx` into a records-region view: its webId, entityUuid, - * and draftId (or a zeroed draftId slot when the entity is not a draft). */ +/** Encode an {@link EntityId} at the given index. */ export function encodeEntityId( bytes: Uint8Array, entityIdx: number, @@ -55,16 +74,32 @@ export function encodeEntityId( const base = entityIdx * ENTITY_ID_BYTES; writeUuid(bytes, base, webId); writeUuid(bytes, base + UUID_BYTES, entityUuid); - writeUuid(bytes, base + UUID_BYTES * 2, draftId ?? ZERO_UUID); + const draftOffset = base + UUID_BYTES * 2; + if (draftId) { + writeUuid(bytes, draftOffset, draftId); + } else { + bytes.fill(0, draftOffset, draftOffset + UUID_BYTES); + } +} + +function isZeroSlot(bytes: Uint8Array, offset: number): boolean { + for (let i = 0; i < UUID_BYTES; i++) { + if (bytes[offset + i] !== 0) { + return false; + } + } + return true; } -/** Reconstruct the EntityId for `entityIdx` from a records-region view. */ +/** Reconstruct the {@link EntityId} at the given index. */ export function decodeEntityId(bytes: Uint8Array, entityIdx: number): EntityId { const base = entityIdx * ENTITY_ID_BYTES; const webId = readUuid(bytes, base) as WebId; const entityUuid = readUuid(bytes, base + UUID_BYTES) as EntityUuid; - const draftIdRaw = readUuid(bytes, base + UUID_BYTES * 2); - const draftId = - draftIdRaw === ZERO_UUID ? undefined : (draftIdRaw as DraftId); + const draftOffset = base + UUID_BYTES * 2; + const isDraft = !isZeroSlot(bytes, draftOffset); + const draftId = isDraft + ? (readUuid(bytes, draftOffset) as DraftId) + : undefined; return entityIdFromComponents(webId, entityUuid, draftId); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts index 7e1cac20c78..5a5a8419327 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; import { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts index 9c1180576af..98177f279ed 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts @@ -1,92 +1,45 @@ /** - * Per-entity visual encoding for the individual-entity (flat) tiers: colour by - * type and size by degree (see `LAYOUT-MODES.md` "Cross-cutting features"). + * Per-entity visual style: colour by type hierarchy, size by degree. * - * Colour is hierarchy-aware: the hue comes from the entity's root type and the - * shade from how specific its actual type is (its depth in the `allOf` tree), so - * the type tree reads at a glance (every Company-rooted entity is a shade of one - * hue, a Customer a lighter shade than a bare Company) instead of N arbitrary - * palette colours. - * - * Hue is keyed off a STABLE colour slot the type registry assigns each type - * (sorted-batch by base URL, append-only), spread around the wheel by the golden - * angle. The slot -- not an arrival-order intern index -- is what makes a type's - * colour identical across reloads: it depends on the URL, not on the order types - * stream in. - * - * Size is by degree, subtly: a hub reads as a slightly larger dot. The radius is - * also the layout's non-overlap box, so hubs claim a little more room. + * Hue is assigned per root type via the golden angle. Lightness encodes + * depth in the `allOf` tree, so subtypes within the same family are + * distinguishable shades of one hue. A hash-based jitter separates + * siblings at the same depth. */ import { extractBaseUrl } from "@blockprotocol/type-system"; -import { graphColors, hslToRgb } from "../visual-style"; +import { oklchToRgb } from "../math/color"; +import { murmur3StringUnit } from "../math/hash"; +import { graphColors } from "../visual-style"; import type { Color } from "../frames"; import type { TypeIdx } from "../ids"; import type { TypeRegistry } from "./stores/type-registry"; -/** Hue step between successive colour slots (degrees); well-separated, stable. */ +/** Hue step between successive colour slots (degrees). */ const GOLDEN_ANGLE_DEG = 137.508; -const HUE_SATURATION = 0.62; -/** - * Lightness encodes the type's place in its root's family: a depth gradient - * (deeper subtype -> lighter) plus a small per-type jitter so siblings at the - * same depth (Customer vs Supplier under Company) stay distinguishable. The hue - * is shared across the family, so the tree still reads at a glance. - */ -const ROOT_LIGHTNESS = 0.42; -const LIGHTNESS_PER_DEPTH = 0.045; -const SIBLING_JITTER = 0.045; -const MIN_LIGHTNESS = 0.32; -const MAX_LIGHTNESS = 0.68; +const NODE_CHROMA = 0.13; +const ROOT_LIGHTNESS = 0.55; +const LIGHTNESS_PER_DEPTH = 0.04; +const SIBLING_JITTER = 0.035; +const MIN_LIGHTNESS = 0.4; +const MAX_LIGHTNESS = 0.78; const MAX_SHADE_DEPTH = 4; const DOT_ALPHA = 220; -/** Base dot radius (world units) and the (subtle) per-degree growth factor. */ const DOT_BASE_RADIUS = 4; const DOT_DEGREE_K = 0.35; -/** Fallback for an entity whose type/root can't be resolved (untyped, unsent). */ const NEUTRAL: Color = [...graphColors.fallbackEntity]; -/** - * Edges share the nodes' stable slot hues but with lower saturation and mid - * lightness, so a link reads as related to its type yet sits behind the dots. - */ -const EDGE_SATURATION = 0.58; -const EDGE_LIGHTNESS = 0.46; +const EDGE_CHROMA = 0.09; +const EDGE_LIGHTNESS = 0.58; const EDGE_ALPHA = 180; const EDGE_NEUTRAL: Color = [...graphColors.fallbackEntity]; -/** - * Colour for a FRONTIER node: a fetched entity that is a link endpoint of a query root but not - * itself a root. A cool, desaturated, semi-transparent grey so it reads as "ghosted -- click to - * expand", receding behind the fully-coloured roots and staying distinct from both the type hues - * and the focus dim. - */ +/** Colour for a frontier node (fetched link endpoint, not itself a query root). */ export const FRONTIER_COLOR: Color = [...graphColors.frontier]; -/** - * Deterministic FNV-1a 32-bit hash of a string mapped to the unit interval - * [0, 1). Stable across reloads and sessions, so a value derived from a type's - * URL is identical every time, unlike an arrival-order intern index whose value - * depends on the order types stream in. - */ -/* eslint-disable no-bitwise */ -function hashUnit(value: string): number { - let hash = 0x811c9dc5; - for (let index = 0; index < value.length; index++) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0) / 0x100000000; -} -/* eslint-enable no-bitwise */ - -/** - * Golden-angle hue (degrees) for a type's stable colour slot, or undefined when - * the type has no slot yet (its schema hasn't been registered). - */ function slotHue( typeIdx: TypeIdx | undefined, types: TypeRegistry, @@ -99,9 +52,8 @@ function slotHue( } /** - * The most specific type in a set: greatest `allOf` depth, ties broken by the - * smallest idx (deterministic). This is the type whose shade/icon best - * represents the entity (a `Customer`, not its `Company` ancestor). + * The most specific type in a set: greatest `allOf` depth, ties broken + * by the smallest idx. */ export function primaryTypeOfSet( typeIdxs: Iterable, @@ -123,11 +75,7 @@ export function primaryTypeOfSet( return best; } -/** - * Hierarchy-aware colour for a (primary) type: hue from its root's colour slot, - * lightness shade from its depth. Falls back to a neutral grey when the type or - * its root is unknown. - */ +/** Colour for a type. Falls back to neutral grey when the type or its root is unknown. */ export function colorForType( typeIdx: TypeIdx | undefined, types: TypeRegistry, @@ -144,9 +92,10 @@ export function colorForType( return NEUTRAL; } const depth = Math.min(info.depth, MAX_SHADE_DEPTH); - // Per-type jitter from the type's own base URL: separates same-depth siblings - // within the shared family hue. Hash-based, so it is stable across reloads. - const fraction = hashUnit(extractBaseUrl(info.url)); + + // Hash-based jitter separates same-depth siblings within a family hue. + const fraction = murmur3StringUnit(extractBaseUrl(info.url)); + const jitter = (fraction - 0.5) * 2 * SIBLING_JITTER; const lightness = Math.min( MAX_LIGHTNESS, @@ -155,16 +104,15 @@ export function colorForType( ROOT_LIGHTNESS + depth * LIGHTNESS_PER_DEPTH + jitter, ), ); - const [red, green, blue] = hslToRgb(hue, HUE_SATURATION, lightness); + const [red, green, blue] = oklchToRgb(lightness, NODE_CHROMA, hue); return [red, green, blue, DOT_ALPHA]; } /** - * Stable colour for an edge of a given (primary link) type: golden-angle hue - * from the LINK type's OWN colour slot. Link types all share the `Link` root, so - * keying off the root would collapse every edge to one colour; the own-type slot - * gives each link type a distinct hue, identical across reloads. Lower - * saturation / mid lightness than nodes so edges sit behind them. + * Colour for an edge by its link type. + * + * Uses the link type's own colour slot rather than its root's, because + * all link types share a single root. */ export function edgeColorForType( typeIdx: TypeIdx | undefined, @@ -174,11 +122,11 @@ export function edgeColorForType( if (hue === undefined) { return EDGE_NEUTRAL; } - const [red, green, blue] = hslToRgb(hue, EDGE_SATURATION, EDGE_LIGHTNESS); + const [red, green, blue] = oklchToRgb(EDGE_LIGHTNESS, EDGE_CHROMA, hue); return [red, green, blue, EDGE_ALPHA]; } -/** Subtle by-degree dot radius in world units: r = base·(1 + ln(1+deg)·k). */ +/** By-degree dot radius in world units: `base * (1 + ln(1 + deg) * k)`. */ export function radiusForDegree(degree: number): number { return DOT_BASE_RADIUS * (1 + Math.log(1 + degree) * DOT_DEGREE_K); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts index 70451330646..c8c23322faa 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts @@ -1,14 +1,7 @@ +/** Web worker entry point. Dispatches messages to {@link GraphWorker}. */ + import { GraphWorker } from "./core/graph-worker"; -/** - * Web worker entry point. Thin message dispatch to GraphWorker. - * - * The worker owns two emission channels, wired here to postMessage: - * - StructureFrame: rare (topology change), structured-clone with the small - * per-leaf edge-topology buffers transferred. - * - PositionsFrame: frequent while settling; its flat buffers are transferred - * (zero-copy) and held in a ref on the main thread, so nothing accumulates. - */ import type { PositionsFrame, StructureFrame } from "../frames"; import type { MainToWorkerMessage, WorkerToMainMessage } from "./protocol"; @@ -73,6 +66,7 @@ globalThis.onmessage = ({ data }: MessageEvent) => { worker.onLayoutMessage = (msg) => post(msg); worker.onStructureFrame = (frame) => postStructure(frame); worker.onPositionsFrame = (frame) => postPositions(frame); + post({ type: "READY" }); } catch (err) { post({ type: "ERROR", message: String(err) }); @@ -106,8 +100,6 @@ globalThis.onmessage = ({ data }: MessageEvent) => { const t0 = performance.now(); const deltas = worker.ingestBatch(data.entities); const tIngest = performance.now(); - // The worker owns topology maintenance (cluster-tree vs flat layout), - // gated by mode, so a tree rebuild can't clear the flat layout. worker.commitStructure({ deltas }); // Re-style if an expand flipped an already-rendered frontier node to a root (hierarchical // tier only; the flat tier already restyled in the commit). @@ -189,5 +181,10 @@ globalThis.onmessage = ({ data }: MessageEvent) => { worker.setHighlight(data.entityIdxs); break; } + + default: { + const _: never = data; + break; + } } }; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.bench.ts new file mode 100644 index 00000000000..b0aeae38300 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.bench.ts @@ -0,0 +1,54 @@ +/** + * Edge-aggregation pair keying. `makePairKey` allocates a template-string key + * plus a result object for every cluster pair it classifies, and it runs inside + * the aggregation loop on every structure commit (and the bezier build reads the + * same pair keys per frame). Cluster-pair counts are far smaller than edge + * counts, so this bench sizes that allocation cost against realistic pair counts + * -- it exists to CONFIRM the aggregation keying is (or is not) a bottleneck, not + * to assume it. The full `EdgeAggregator.update` / `buildBezierSegments` path + * needs a live cut + cluster tree and is covered end-to-end by + * `core/commit-rebuild.bench.ts` (hierarchical case). + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { ClusterId } from "../../ids"; +import { makePairKey } from "./edge-aggregation"; + +const PAIR_COUNTS: readonly number[] = [500, 2_000, 8_000]; + +/** A pool of cluster ids shaped like the real ones (`cluster:type:`). */ +function clusterIdPool(size: number): ClusterId[] { + const pool: ClusterId[] = []; + for (let index = 0; index < size; index++) { + pool.push(ClusterId(`cluster:type:${index},${(index * 7) % 97}`)); + } + return pool; +} + +for (const pairCount of PAIR_COUNTS) { + // A distinct (a, b) index pair per iteration of the inner loop, drawn from a + // modest id pool so keys collide the way sibling cluster pairs do in practice. + const pool = clusterIdPool(Math.max(32, Math.ceil(Math.sqrt(pairCount)) * 4)); + const pairs: [number, number][] = []; + for (let index = 0; index < pairCount; index++) { + const a = (index * 3) % pool.length; + const b = (index * 5 + 1) % pool.length; + pairs.push([a, b === a ? (b + 1) % pool.length : b]); + } + + describe(`makePairKey (${pairCount} pairs)`, () => { + bench("classify every pair", () => { + let keyLengthSum = 0; + for (const [a, b] of pairs) { + keyLengthSum += makePairKey(pool[a]!, pool[b]!).key.length; + } + if (keyLengthSum < 0) { + throw new Error("unreachable"); + } + }); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts index 7dcbe364e1e..76c85e0ba0d 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts @@ -1,7 +1,8 @@ /* eslint-disable no-param-reassign */ import { MutableCircle } from "../../geometry"; import { ClusterId } from "../../ids"; -import { graphColors, hslToRgb } from "../../visual-style"; +import { hslToRgb } from "../../math/color"; +import { graphColors } from "../../visual-style"; import { Column } from "../collections/column"; import { subclusterByLinks } from "./community"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.bench.ts new file mode 100644 index 00000000000..56d88d98f0d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.bench.ts @@ -0,0 +1,92 @@ +/** + * Community sub-clustering hot path (runs when a large type-set cluster is + * opened in hierarchical mode). Splits the pipeline into its three stages so the + * cost of the CSR build (array-of-arrays + `{neighbor, weight}` object per edge), + * the BFS components pass, and the label-propagation inner loop (a fresh `Map` + * per node per iteration, up to 20 iterations) can each be attributed. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/hierarchy/community.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildCommunityInputs } from "../bench-fixtures"; +import { buildInducedCsr, connectedComponents } from "../csr-graph"; +import { boundedLabelPropagation } from "./community"; + +import type { GraphShape } from "../bench-fixtures"; + +interface Case { + readonly label: string; + readonly shape: GraphShape; +} + +const CASES: readonly Case[] = [ + { + label: "small (2k nodes / 6k links)", + shape: { + nodeCount: 2_000, + linkCount: 6_000, + typeCount: 8, + hubCount: 30, + rootFraction: 1, + seed: 11, + }, + }, + { + label: "medium (8k nodes / 24k links)", + shape: { + nodeCount: 8_000, + linkCount: 24_000, + typeCount: 8, + hubCount: 60, + rootFraction: 1, + seed: 12, + }, + }, + { + label: "large (20k nodes / 60k links)", + shape: { + nodeCount: 20_000, + linkCount: 60_000, + typeCount: 8, + hubCount: 120, + rootFraction: 1, + seed: 13, + }, + }, +]; + +for (const { label, shape } of CASES) { + // Inputs are built once and only READ by the pipeline, so they can be shared + // across iterations. buildInducedCsr allocates fresh output every call. + const { entityIdxs, links } = buildCommunityInputs(shape); + const csr = buildInducedCsr(entityIdxs, links); + const components = connectedComponents(csr); + const largestComponent = components.reduce( + (best, component) => (component.length > best.length ? component : best), + components[0] ?? [], + ); + + describe(`community detection: ${label}`, () => { + bench("buildInducedCsr", () => { + buildInducedCsr(entityIdxs, links); + }); + + bench("connectedComponents", () => { + connectedComponents(csr); + }); + + bench("boundedLabelPropagation (largest component)", () => { + boundedLabelPropagation(csr, largestComponent); + }); + + bench("full: CSR + components + label propagation", () => { + const freshCsr = buildInducedCsr(entityIdxs, links); + for (const component of connectedComponents(freshCsr)) { + boundedLabelPropagation(freshCsr, component); + } + }); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.bench.ts new file mode 100644 index 00000000000..97a1db7e9b5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.bench.ts @@ -0,0 +1,110 @@ +/** + * Full-layout settle cost for the two individual-entity engines. `build + settle` + * is the whole time-to-laid-out a user waits on: matrix/seed construction plus + * every solver iteration to convergence. It shows why flat-force (cola `Descent`, + * O(N^2) per step + an O(N^2) distance matrix at build) is capped at 200 nodes + * and community-force (FA2) takes the medium tier. + * + * Settling dominates and is expensive, so these use a fixed, small iteration + * count (`SETTLE_OPTS`) rather than vitest's default time budget; treat the means + * as ballpark (few samples), the cross-size / cross-engine SHAPE is the point. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/layout/force-simulation.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildForceGraph } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createCommunityLayout } from "./community-layout"; +import { createFlatLayout } from "./flat-layout"; + +import type { GraphShape } from "../bench-fixtures"; +import type { ForceNode, LayoutSimulation } from "./force-simulation"; + +/** Exactly `iterations` measured runs (settling is too slow for a time budget). */ +const SETTLE_OPTS = { + time: 0, + iterations: 6, + warmupTime: 0, + warmupIterations: 1, +} as const; + +function shape(nodeCount: number, linkCount: number, seed: number): GraphShape { + return { + nodeCount, + linkCount, + typeCount: 1, + hubCount: Math.max(4, Math.round(nodeCount / 40)), + rootFraction: 1, + seed, + }; +} + +/** Drive the phase machine to convergence (each engine self-caps its iterations). */ +function settle(layout: LayoutSimulation): void { + while (!layout.isSettled) { + if (!layout.tick(60_000)) { + break; + } + } +} + +/** Fresh node objects: the solvers mutate node x/y in place, so a shared array + * would let run N+1 warm-start from run N's settled positions. */ +function cloneNodes(nodes: readonly ForceNode[]): ForceNode[] { + return nodes.map((node) => ({ ...node })); +} + +const FLAT_CASES: readonly GraphShape[] = [ + shape(50, 90, 101), + shape(120, 240, 102), + shape(200, 400, 103), +]; + +for (const graphShape of FLAT_CASES) { + describe(`flat-force build + settle (${graphShape.nodeCount} nodes)`, () => { + const { nodes, edges } = buildForceGraph(graphShape); + bench( + "cola Descent", + () => { + const buffer = new FlatGraphBuffer(nodes.length); + settle(createFlatLayout(cloneNodes(nodes), edges, buffer)); + }, + SETTLE_OPTS, + ); + }); +} + +const COMMUNITY_CASES: readonly GraphShape[] = [ + shape(500, 1_200, 201), + shape(1_500, 4_000, 202), + shape(3_000, 8_000, 203), +]; + +for (const graphShape of COMMUNITY_CASES) { + describe(`community-force (${graphShape.nodeCount} nodes)`, () => { + const { nodes, edges } = buildForceGraph(graphShape); + + // Synchronous build (Louvain + sparse-stress seed alloc + FA2 matrices); this + // blocks before the first frame can stream. + bench( + "build only (Louvain + seed + matrices)", + () => { + const buffer = new FlatGraphBuffer(nodes.length); + createCommunityLayout(cloneNodes(nodes), edges, buffer); + }, + SETTLE_OPTS, + ); + + bench( + "build + settle (seed + FA2)", + () => { + const buffer = new FlatGraphBuffer(nodes.length); + settle(createCommunityLayout(cloneNodes(nodes), edges, buffer)); + }, + SETTLE_OPTS, + ); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts index ff8f4acaa80..62a2322965b 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts @@ -1,19 +1,8 @@ +/** Message protocol for the worker <-> main thread boundary. */ + import type { VizConfig } from "../config"; import type { PositionsFrame, StructureFrame } from "../frames"; import type { ClusterId, EntityIdx, VizMode } from "../ids"; -/** - * Message types for the worker <-> main thread boundary. - * - * Each message variant is its own interface so it's self-documenting, - * individually importable, and easy to match on. - * - * The worker owns all heavy state. The main thread receives only: - * - {@link StructureFrameMessage}: identities + topology, on cut change. - * - {@link PositionsFrameMessage}: bounded cluster positions + edge geometry, - * per tick while settling. - * - {@link LayoutCreatedMessage} / {@link LayoutDestroyedMessage}: the - * `SharedArrayBuffer` lifecycle for an open leaf's entity positions. - */ import type { EntityId, LinkData, @@ -24,21 +13,13 @@ import type { export interface TypeSchemaEntry { readonly url: VersionedUrl; readonly title: string; - /** - * For a link type, its inverse (target -> source) title, e.g. "Member Of" for "Has Member". - * Lets the worker label a reverse lane with the inverse title (a lane is per direction). - */ + /** For a link type, the inverse (target -> source) title, e.g. "Member Of" for "Has Member". */ readonly inverseTitle?: string; readonly icon?: string; readonly allOfRefs: readonly VersionedUrl[]; } -/** - * A property type's display title keyed by its base URL. Shipped alongside - * {@link TypeSchemaEntry} so the worker can render a property-based cluster label - * ("Destination = ...") with the human title rather than a raw base URL. The worker - * holds property VALUES (on {@link IngestEntity}); these supply the names for them. - */ +/** A property type's human-readable title, keyed by base URL. */ export interface PropertySchemaEntry { readonly baseUrl: string; readonly title: string; @@ -50,20 +31,14 @@ export interface IngestEntity { readonly label?: string; readonly isLink: boolean; /** - * Whether this entity is a query ROOT. A non-root node is a FRONTIER node (a fetched link - * endpoint, rendered greyed-out until expanded). Carried on the ingest itself -- co-located with - * the entity -- so it is applied the moment the entity is interned and the first render already - * has the right colour, with no separate roots message. An expand re-sends a frontier node with - * this set to flip it. Always false for links. + * Whether this entity is a query root. Non-root nodes are frontier nodes + * (fetched link endpoints, rendered greyed-out until expanded). + * + * Always false for links. */ readonly isRoot: boolean; readonly linkData?: LinkData; - /** - * The entity's property values, shipped for NODE entities so the worker can name an - * embedding cluster from the distinctive (property = value) signature its members - * share (see {@link PropertySchemaEntry}). The worker extracts the scalar features it - * needs itself; links carry none (they are never embedding-clustered). - */ + /** Property values for node entities. Absent for links. */ readonly properties?: PropertyObject; } @@ -105,17 +80,17 @@ export interface EmbeddingClusteringResultMessage { } /** - * The currently-visible representative of one of a selected node's neighbors: the entity - * itself when it is individually rendered (the flat tier, or an open hierarchical leaf), else - * the visible cluster bubble it is collapsed into. Drives cross-cluster ego-highlight. + * The currently visible representative of a selected node's neighbor: the entity + * itself when individually rendered, or the cluster bubble it is collapsed into. */ export type EgoTarget = | { readonly kind: "entity"; readonly entityIdx: EntityIdx } | { readonly kind: "cluster"; readonly clusterId: ClusterId }; /** - * Ask for a selected node's ego: its neighbors' visible representatives. Async; the reply is - * an {@link EgoResultMessage} correlated by {@link requestId}. + * Request a selected node's ego: its neighbors' visible representatives. + * + * Reply: {@link EgoResultMessage}, correlated by {@link requestId}. */ export interface QueryEgoMessage { readonly type: "QUERY_EGO"; @@ -125,8 +100,8 @@ export interface QueryEgoMessage { } /** - * Pin a hierarchical leaf cluster open (its ancestors too) regardless of zoom, or null to - * clear. Set on selection; the worker forces it into the cut until cleared (birds-eye view). + * Pin a hierarchical leaf cluster open (and its ancestors) regardless of zoom, + * or `null` to clear. */ export interface SetPinnedMessage { readonly type: "SET_PINNED"; @@ -134,9 +109,9 @@ export interface SetPinnedMessage { } /** - * Set the highlighted entities (a selection's ego now, a path later): the worker keeps these - * at full colour and dims everyone else. Empty restores full colour. Generic -- the worker - * just dims the complement; the main thread decides what the set is. + * Set the highlighted entities. All others are dimmed. + * + * Empty restores full colour. */ export interface SetHighlightMessage { readonly type: "SET_HIGHLIGHT"; @@ -144,10 +119,9 @@ export interface SetHighlightMessage { } /** - * Ask which link entities a clicked highway lane aggregates. Async; the reply is - * a {@link HighwayLinksResultMessage} correlated by {@link requestId}. The - * {@link laneId} is the lane's index in the worker's visual-edge list, carried - * to the main thread as the bezier segment `id` (see `RenderBezierBuffers.ids`). + * Request the link entities a highway lane aggregates. + * + * Reply: {@link HighwayLinksResultMessage}, correlated by {@link requestId}. */ export interface QueryHighwayLinksMessage { readonly type: "QUERY_HIGHWAY_LINKS"; @@ -171,8 +145,9 @@ export interface ReadyMessage { } /** - * Topology changed (cut/ingest). Carries identities, styles, and edge - * topology. Sent rarely; held in a ref on the main thread with a version bump. + * Topology changed (cut or ingest). Carries identities, styles, and edge topology. + * + * Sent infrequently relative to {@link PositionsFrameMessage}. */ export interface StructureFrameMessage { readonly type: "STRUCTURE_FRAME"; @@ -203,20 +178,18 @@ export interface EmbeddingClusteringNeededMessage { } /** - * An open leaf's entity positions are now backed by this buffer. Positions are - * LOCAL to the leaf center (`[x0, y0, x1, y1, ...]` after a leading int32 - * version counter); the main thread adds the leaf's world position. + * An open leaf's entity positions are now backed by this buffer. + * + * Positions are local to the leaf center: `[version_i32, x0, y0, x1, y1, ...]`. */ export interface LayoutCreatedMessage { readonly type: "LAYOUT_CREATED"; readonly clusterId: ClusterId; - /** SharedArrayBuffer (or ArrayBuffer fallback) with `[version, ...positions]`. */ readonly buffer: SharedArrayBuffer | ArrayBuffer; readonly nodeIds: readonly string[]; /** - * Present when `buffer` is a flat-tier `FlatGraphBuffer` — positions + radii + - * colours in regions sized by this capacity, so the main thread builds all - * three views. Absent for a positions-only entity SharedArrayBuffer. + * Present when `buffer` is a flat-tier `FlatGraphBuffer` (positions + radii + + * colours in regions sized by this capacity). Absent for a positions-only buffer. */ readonly flatCapacity?: number; } @@ -233,21 +206,17 @@ export interface LayoutDestroyedMessage { readonly clusterId: ClusterId; } -/** Which main-thread-held SharedArrayBuffer a {@link BufferRepublishedMessage} - * replaces. A discriminated union so new growable buffers (e.g. the EntityId - * map) slot in. */ +/** Identifies which shared buffer a {@link BufferRepublishedMessage} replaces. */ export type RepublishTarget = { readonly kind: "layout"; readonly clusterId: ClusterId; }; /** - * A SharedArrayBuffer the main thread holds was re-allocated: it outgrew its in-place - * `maxByteLength` ceiling (or the platform can't grow shared buffers in place). The bytes were - * copied across, so nothing is lost (the main thread just swaps to `buffer` and - * re-attaches its Atomics version watcher). In-place growth sends nothing (the main thread - * already shares the same, now-larger buffer, and its length-tracking views auto-extend); - * this fires only on the rare re-allocation. `target` says which held buffer to replace. + * A shared buffer was re-allocated (outgrew its `maxByteLength` ceiling, or the + * platform cannot grow shared buffers in place). All bytes are preserved. + * + * Only sent on re-allocation; in-place growth requires no message. */ export interface BufferRepublishedMessage { readonly type: "BUFFER_REPUBLISHED"; @@ -258,12 +227,10 @@ export interface BufferRepublishedMessage { } /** - * The EntityIdx -> EntityId join map is backed by this SharedArrayBuffer (see worker/entity-id-buffer.ts). - * The worker is the sole writer; the main thread reads it on demand (hover/pick) to turn a - * rendered record's `entityIdx` back into its EntityId, so no per-entity data crosses the - * boundary. Sent on first publish and re-sent (same `type`, new `buffer`) whenever the buffer - * is re-allocated; in-place growth needs no message (the main thread shares the same, - * now-larger buffer and reads it fresh each time). + * The EntityIdx to EntityId lookup table, backed by a shared buffer. + * + * The worker is the sole writer. Sent on first publish and re-sent on + * re-allocation; in-place growth needs no message. */ export interface EntityIdMapMessage { readonly type: "ENTITY_ID_MAP"; @@ -279,9 +246,9 @@ export interface ErrorMessage { } /** - * Reply to {@link QueryEgoMessage}: the visible representative of each of the node's - * neighbors ({@link EgoTarget}) -- an entity dot or the cluster it collapses into. Neighbors - * not in the current view are omitted. + * Reply to {@link QueryEgoMessage}. + * + * Neighbors not in the current view are omitted. */ export interface EgoResultMessage { readonly type: "EGO_RESULT"; @@ -290,9 +257,9 @@ export interface EgoResultMessage { } /** - * Reply to {@link QueryHighwayLinksMessage}: the link entities the clicked - * highway lane aggregates, correlated by {@link requestId}. Empty when the lane - * has no aggregate identity (an individual edge, or an out-of-range id). + * Reply to {@link QueryHighwayLinksMessage}. + * + * Empty when the lane has no aggregate identity (individual edge or out-of-range id). */ export interface HighwayLinksResultMessage { readonly type: "HIGHWAY_LINKS_RESULT"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts index 87f5728ba4b..f3a97ac7832 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; import { mulberry32, parkMillerRng } from "./random"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts index 9b89c0bcb0b..2c3255184a4 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts @@ -1,15 +1,14 @@ /* eslint-disable id-length, no-bitwise */ -/** - * Deterministic seeded PRNGs, so layouts that depend on randomness (annealing, jitter, - * Louvain seeding) reproduce run to run with no dependence on Math.random. - */ +/** Deterministic seeded PRNGs for reproducible layouts. */ /** - * mulberry32: a fast 32-bit PRNG returning a float in [0, 1). A given seed reproduces the - * exact sequence, so a layout anneals or lays out identically run to run. + * mulberry32: a 32-bit PRNG. Returns a float in [0, 1). + * + * A given seed always produces the same sequence. */ export function mulberry32(seed: number): () => number { let state = seed >>> 0; + return () => { state = (state + 0x6d2b79f5) | 0; let t = Math.imul(state ^ (state >>> 15), 1 | state); @@ -19,14 +18,17 @@ export function mulberry32(seed: number): () => number { } /** - * Park-Miller minimal-standard PRNG returning a float in (0, 1). A full-period - * multiplicative generator, used to seed Louvain so community detection is reproducible. + * Park-Miller minimal-standard PRNG. Returns a float in (0, 1); + * never returns exactly 0 or 1. + * + * Full-period: cycles through all 2^31 - 2 values before repeating. */ export function parkMillerRng(seed: number): () => number { let state = seed % 2147483647; if (state <= 0) { state += 2147483646; } + return () => { state = (state * 48271) % 2147483647; return state / 2147483647; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts new file mode 100644 index 00000000000..b8b6de00331 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts @@ -0,0 +1,132 @@ +/** + * Store-ingestion hot path (runs once per ingested entity; the whole page-load + * stream flows through it). Isolates the per-entity type-set keying, the entity + * interner, and the link adjacency + `linksForEntity` allocation, so a regression + * in any of them shows up here without the layout/commit noise on top. + * + * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts` + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { EntityIdx, TypeIdx, TypeSetIdx } from "../../ids"; +import { benchEntityId, buildCommunityInputs } from "../bench-fixtures"; +import { ReadonlySortedSet } from "../collections/readonly-sorted-set"; +import { EntityStore } from "./entity-store"; +import { TypeSetStore } from "./type-set-store"; + +import type { GraphShape } from "../bench-fixtures"; + +const compareTypeIdx = (lhs: TypeIdx, rhs: TypeIdx): number => lhs - rhs; + +interface Case { + readonly label: string; + readonly shape: GraphShape; +} + +const CASES: readonly Case[] = [ + { + label: "small (1k nodes / 2k links)", + shape: { + nodeCount: 1_000, + linkCount: 2_000, + typeCount: 12, + hubCount: 20, + rootFraction: 1, + seed: 1, + }, + }, + { + label: "medium (5k nodes / 10k links)", + shape: { + nodeCount: 5_000, + linkCount: 10_000, + typeCount: 24, + hubCount: 40, + rootFraction: 1, + seed: 2, + }, + }, + { + label: "large (20k nodes / 40k links)", + shape: { + nodeCount: 20_000, + linkCount: 40_000, + typeCount: 48, + hubCount: 80, + rootFraction: 1, + seed: 3, + }, + }, +]; + +/** A representative per-node direct type index list (mostly one type, some two). */ +function typeIdxsFor(nodeIndex: number, typeCount: number): TypeIdx[] { + const primary = TypeIdx(nodeIndex % typeCount); + if (nodeIndex % 5 === 0) { + return [primary, TypeIdx((nodeIndex * 7 + 1) % typeCount)]; + } + return [primary]; +} + +for (const { label, shape } of CASES) { + describe(`type-set keying: ${label}`, () => { + // `ingestBatch` builds a ReadonlySortedSet + calls getOrCreate ONCE in + // #peekGroup and AGAIN in insertNodeEntity for every node entity, so the + // per-node keying cost below is paid twice per streamed node. + bench("ReadonlySortedSet + getOrCreate once per node", () => { + const store = new TypeSetStore(); + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + const set = new ReadonlySortedSet( + typeIdxsFor(nodeIndex, shape.typeCount), + compareTypeIdx, + ); + store.getOrCreate(set, shape.typeCount); + } + }); + + bench("ReadonlySortedSet + getOrCreate twice per node (peek + insert)", () => { + const store = new TypeSetStore(); + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + const peekSet = new ReadonlySortedSet( + typeIdxsFor(nodeIndex, shape.typeCount), + compareTypeIdx, + ); + store.getOrCreate(peekSet, shape.typeCount); + const insertSet = new ReadonlySortedSet( + typeIdxsFor(nodeIndex, shape.typeCount), + compareTypeIdx, + ); + store.getOrCreate(insertSet, shape.typeCount); + } + }); + }); + + describe(`entity interning: ${label}`, () => { + bench("EntityStore.tryInsert per node", () => { + const store = new EntityStore(); + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + const [, idx] = store.tryInsert(benchEntityId(nodeIndex)); + store.setTypeGroup(idx, TypeSetIdx(nodeIndex % shape.typeCount)); + } + }); + }); + + describe(`link adjacency: ${label}`, () => { + // linksForEntity allocates a fresh LinkEndpoint[] (one object literal per + // incident link) on EVERY call, and several hot paths call it just to read + // `.length` (degree). This bench sweeps every entity's adjacency once, the + // shape of #seedFlatNodes / #buildEntityFanOut / community feature scans. + bench("build LinkStore + linksForEntity over all nodes", () => { + const { links } = buildCommunityInputs(shape); + let degreeSum = 0; + for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { + degreeSum += links.linksForEntity(EntityIdx(nodeIndex)).length; + } + if (degreeSum < 0) { + throw new Error("unreachable"); + } + }); + }); +} From f3b9ccfb2492995602cdce3f0a280086ded38eea Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:21:50 +0200 Subject: [PATCH 41/78] feat: performance + code review --- .../graph-visualizer-2/worker/PERFORMANCE.md | 186 +++++++++--------- .../worker/stores/entity-store.ts | 35 +--- .../worker/stores/link-store.ts | 9 +- .../worker/stores/property-store.ts | 72 +++---- .../worker/stores/type-registry.ts | 23 +-- .../worker/stores/type-set-store.ts | 18 +- 6 files changed, 154 insertions(+), 189 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md index a804618a3b7..19264acdbbe 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md @@ -12,17 +12,17 @@ Headline findings: - **A no‑op `commitStructure()` is not free.** In the flat/community tier every commit unconditionally re‑sorts all node indices, rescans topology with two throwaway `Set`s, re‑writes per‑node style for the whole graph, and rebuilds - every render edge — even when nothing changed. Measured **8.1 ms per no‑op + every render edge — even when nothing changed. Measured **8.4 ms per no‑op commit at 1,500 nodes** (`core/commit-rebuild.bench.ts`). - **Streaming ingest pays that cost per batch.** The same 2,000‑node / - 5,000‑link graph costs **46.7 ms delivered as one batch+commit vs 1,018 ms - delivered as 100‑entity batches — a 21.8× penalty**. + 5,000‑link graph costs **42.5 ms delivered as one batch+commit vs 1,043 ms + delivered as 100‑entity batches — a 24.5× penalty**. - **Community detection is dominated by `boundedLabelPropagation`**, which allocates a fresh `Map` per node per iteration (≤20 iterations): **111 ms for - a 20k‑node component**, ~400–600× the cost of the connected‑components pass. + a 20k‑node component**, ~400× the cost of the connected‑components pass. - **Buffers are handled well.** `FlatGraphBuffer` grows with 1.5× geometric slack (`flatCapacityFor`) and `Column` doubles; both stay amortized O(N). A - naïve grow‑to‑exact‑count path would be **18× slower at 50k records**, so this + naïve grow‑to‑exact‑count path would be **~21× slower at 50k records**, so this is worth a regression test, not a fix. --- @@ -51,16 +51,16 @@ Benchmarks are Vitest `bench`/`describe` suites in colocated `*.bench.ts` files. **`vitest bench` works in this app unchanged** — no config edits were needed. The only wrinkle is that the `vitest` binary is hoisted to the monorepo root (the app -has no `node_modules/.bin/vitest`), so it must be invoked by path. From the app -directory: +has no `node_modules/.bin/vitest`, and `yarn vitest` is not wired as a script), so +it must be invoked by path. The reliable, verified form from the app directory: ```bash cd apps/hash-frontend -../../node_modules/.bin/vitest bench --run +node ../../node_modules/vitest/vitest.mjs bench --run ``` -(If your shell resolves the hoisted bin on `PATH`, `yarn vitest bench --run …` -also works; the explicit path is the reliable form.) +(The absolute path `/node_modules/.bin/vitest bench --run …` also works +from anywhere; the `node …/vitest.mjs` form above is the portable one.) ### Measurement caveats @@ -88,45 +88,46 @@ one full pass over the whole graph. | Case | small (1k/2k) | medium (5k/10k) | large (20k/40k) | | ---------------------------------------------------- | ------------- | --------------- | --------------- | -| type‑set: `getOrCreate` **once**/node | 0.088 ms | 0.437 ms | 1.70 ms | -| type‑set: `getOrCreate` **twice**/node (peek+insert) | 0.169 ms | 0.846 ms | 3.32 ms | -| `EntityStore.tryInsert`/node | 0.62 ms | 3.50 ms | 14.2 ms | -| build `LinkStore` + `linksForEntity` over all nodes | 0.22 ms | 1.64 ms | 10.1 ms | +| type‑set: `getOrCreate` **once**/node | 0.090 ms | 0.488 ms | 1.81 ms | +| type‑set: `getOrCreate` **twice**/node (peek+insert) | 0.175 ms | 0.967 ms | 3.64 ms | +| `EntityStore.tryInsert`/node | 0.62 ms | 3.64 ms | 14.7 ms | +| build `LinkStore` + `linksForEntity` over all nodes | 0.22 ms | 1.66 ms | 8.63 ms | -The peek+insert row is a stable **~1.9× the once row** at every size — the -`ingestBatch` code path really does key each node entity twice (see F4). +The peek+insert row is a stable **~2.0× the once row** at every size (1.96×, +1.98×, 2.01×) — the `ingestBatch` code path really does key each node entity +twice (see F5). ### 2.2 Community detection — `hierarchy/community.bench.ts` | Stage | 2k/6k | 8k/24k | 20k/60k | | --------------------------------------------- | -------- | -------- | ------- | -| `buildInducedCsr` | 0.23 ms | 1.52 ms | 6.70 ms | -| `connectedComponents` | 0.022 ms | 0.078 ms | 0.29 ms | -| `boundedLabelPropagation` (largest component) | 10.1 ms | 41.3 ms | 111 ms | -| full pipeline (CSR + components + label prop) | 10.4 ms | 47.3 ms | 122 ms | +| `buildInducedCsr` | 0.17 ms | 1.20 ms | 3.62 ms | +| `connectedComponents` | 0.023 ms | 0.090 ms | 0.28 ms | +| `boundedLabelPropagation` (largest component) | 10.2 ms | 44.0 ms | 111 ms | +| full pipeline (CSR + components + label prop) | 10.3 ms | 43.9 ms | 121 ms | -`connectedComponents` is ~10–23× faster than the CSR build and ~400–600× faster +`connectedComponents` is ~7–13× faster than the CSR build and ~400–490× faster than label propagation. **Label propagation is the whole cost** (see F3). ### 2.3 Buffer write + growth — `buffers/growable-buffer.bench.ts` Mean time to write / grow the whole buffer. -| Flat‑tier buffer | 1k | 10k | 50k | -| ------------------------------------ | --------- | --------------- | ------------------- | -| presized: write all records + commit | 0.0024 ms | 0.026 ms | 0.096 ms | -| geometric growth (1.5×, production) | 0.0029 ms | 0.073 ms (2.8×) | 0.45 ms (4.7×) | -| fixed‑step growth (1024, naïve) | 0.0024 ms | 0.109 ms (4.2×) | 1.75 ms (**18.3×**) | +| Flat‑tier buffer | 1k | 10k | 50k | +| ------------------------------------ | --------- | --------------- | ----------------- | +| presized: write all records + commit | 0.0024 ms | 0.029 ms | 0.098 ms | +| geometric growth (1.5×, production) | 0.0023 ms | 0.079 ms (2.7×) | 0.54 ms (5.5×) | +| fixed‑step growth (1024, naïve) | 0.0026 ms | 0.091 ms (3.1×) | 2.06 ms (**21×**) | -| Other | 1k | 10k | 50k | -| ------------------------------------------------------------------- | --------- | --------- | -------- | -| `EntityPositionBuffer.setPosition`×N + commit (per‑tick leaf write) | 0.0015 ms | 0.0093 ms | 0.064 ms | -| `Column.push`×N (geometric) | 0.0013 ms | 0.024 ms | 0.114 ms | +| Other | 1k | 10k | 50k | +| ------------------------------------------------------------------- | --------- | -------- | -------- | +| `EntityPositionBuffer.setPosition`×N + commit (per‑tick leaf write) | 0.0012 ms | 0.011 ms | 0.067 ms | +| `Column.push`×N (geometric) | 0.0014 ms | 0.024 ms | 0.125 ms | Production’s 1.5× geometric growth (`flatCapacityFor`) tracks the presized floor -within ~5×; the naïve grow‑to‑exact‑count path is 18× worse at 50k because every -step re‑allocates and memcpies the whole (non‑resizable, GPU‑uploaded) buffer. -`Column` and the leaf position buffer scale linearly — no problem here. +within ~5.5×; the naïve grow‑to‑exact‑count path is ~21× worse at 50k because +every step re‑allocates and memcpies the whole (non‑resizable, GPU‑uploaded) +buffer. `Column` and the leaf position buffer scale linearly — no problem here. ### 2.4 Layout settle — `layout/force-simulation.bench.ts` @@ -135,16 +136,16 @@ across frames in production). Fixed 6 iterations per row. | flat‑force (cola `Descent`) | 50 | 120 | 200 | | --------------------------- | ------- | ------ | ------ | -| build + settle | 29.8 ms | 199 ms | 409 ms | +| build + settle | 29.3 ms | 189 ms | 377 ms | | community‑force (Louvain + sparse‑stress seed + FA2) | 500 | 1,500 | 3,000 | | ---------------------------------------------------- | ------ | -------- | -------- | -| build only (Louvain + seed alloc + matrices) | 2.1 ms | 6.2 ms | 12.2 ms | -| build + settle | 522 ms | 3,466 ms | 4,391 ms | +| build only (Louvain + seed alloc + matrices) | 1.8 ms | 5.1 ms | 10.5 ms | +| build + settle | 486 ms | 3,438 ms | 4,452 ms | Two takeaways: cola’s O(N²) `Descent` reaches ~0.4 s at 200 nodes, which is exactly why the flat‑force tier is capped at `flatLayoutMaxNodes: 200`. And the -FA2 **settle dominates build by 250–560×** — construction is negligible; the +FA2 **settle dominates build by ~270–680×** — construction is negligible; the iteration loop is the cost. ### 2.5 End‑to‑end commit / rebuild — `core/commit-rebuild.bench.ts` @@ -153,27 +154,30 @@ Driven through the real `GraphWorker` exactly as `entry.ts` does. | No‑op re‑commit (`commitStructure()` with nothing changed) | mean | | ---------------------------------------------------------- | ----------- | -| flat‑force (150 nodes) | 0.125 ms | -| community‑force (1,500 nodes) | **8.14 ms** | -| hierarchical‑lod (8,000 nodes) | 2.91 ms | +| flat‑force (150 nodes) | 0.13 ms | +| community‑force (1,500 nodes) | **8.45 ms** | +| hierarchical‑lod (8,000 nodes) | 3.17 ms | | Ingest 2,000 nodes / 5,000 links | mean | | ------------------------------------------- | -------------------- | -| bulk: 1 batch + 1 commit | 46.7 ms | -| streaming: 100‑entity batches + commit each | **1,018 ms (21.8×)** | +| bulk: 1 batch + 1 commit | 42.5 ms | +| streaming: 100‑entity batches + commit each | **1,043 ms (24.5×)** | -The community‑force no‑op (8 ms) is _more_ expensive than the hierarchical no‑op -(2.9 ms) despite fewer nodes: the flat tiers touch every node and every link on -each commit, while the hierarchical tier only recomputes the cut and the visible -clusters. This is the clearest evidence of the missing incremental commit path. +The community‑force no‑op (8.5 ms) is _more_ expensive than the hierarchical +no‑op (3.2 ms) despite fewer nodes: the flat tiers touch every node and every +link on each commit, while the hierarchical tier only recomputes the cut and the +visible clusters. This is the clearest evidence of the missing incremental commit +path. (The bulk row has high run‑to‑run variance, ±30 % RME, from GC on the big +single allocation; the streaming row is stable at ±2 %, so the ~24× ratio is a +floor, not an artifact.) ### 2.6 Edge aggregation keying — `geometry/edge-aggregation.bench.ts` | `makePairKey` over N pairs | 500 | 2,000 | 8,000 | | -------------------------- | -------- | -------- | -------- | -| classify every pair | 0.012 ms | 0.047 ms | 0.185 ms | +| classify every pair | 0.012 ms | 0.049 ms | 0.194 ms | -At ~23 ns/pair and realistic cluster‑pair counts, **`makePairKey` is not a +At ~24 ns/pair and realistic cluster‑pair counts, **`makePairKey` is not a bottleneck** — this bench exists to rule it out, and it does. --- @@ -203,7 +207,7 @@ every commit**, regardless of whether anything changed: list, calling `this.#links.linksForEntity(...)` per node (`:1411`) — **O(N+E) plus per‑node array allocation every commit** (compounds with F4). -**Evidence:** no‑op re‑commit = **8.14 ms at 1,500 nodes** (§2.5). None of this +**Evidence:** no‑op re‑commit = **8.45 ms at 1,500 nodes** (§2.5). None of this work is needed when the graph is unchanged, and only the style write is needed for a type/highlight‑only change. @@ -217,35 +221,38 @@ re‑derives FA2 settings, conditionally re‑runs Louvain, and resets the settl detector. `resolveEdges` (`:664`) rebuilds a `Map` keyed by a `` `${lo}:${hi}` `` string for every edge on every rebuild. -**Evidence:** the same final graph costs **46.7 ms bulk vs 1,018 ms streamed -(21.8×)** (§2.5). For K batches over N nodes this is ≈ O(K·N) instead of O(N). +**Evidence:** the same final graph costs **42.5 ms bulk vs 1,043 ms streamed +(24.5×)** (§2.5). For K batches over N nodes this is ≈ O(K·N) instead of O(N). ### F3 — `boundedLabelPropagation` allocates per node per iteration — **MEDIUM** `hierarchy/community.ts:63` allocates `const scores = new Map()` **inside the per‑node loop**, run for up to 20 iterations (`:57`), and -`deterministicShuffle` (`:23`) copies the component array (`[...indices]`) once -per iteration. `csr-graph.ts` `buildInducedCsr` also builds an array‑of‑arrays -adjacency with a `{ neighbor, weight }` object per half‑edge (`:31`, `:51`–`:52`) -— 2·E temporary objects — before flattening to typed arrays. +`deterministicShuffle` (`:22`) copies the component array (`[...indices]`, `:23`) +once per iteration (`:59`). Secondary: `csr-graph.ts` `buildInducedCsr` builds an +intermediate `number[][]` adjacency of N growable arrays (`:31`), does 2·E numeric +`push`es (`:52`–`:53`), then a `reduce` scan (`:56`) before flattening into the +typed `neighbors`/`weights` arrays (weights are uniform `1`, `:60`) — an extra +O(N)+2·E of throwaway JS‑array allocation on top of the final CSR. **Evidence:** label propagation is **111 ms for a 20k component** and dominates -the pipeline (§2.2). For a giant component this is hundreds of thousands of Map -allocations. +the pipeline — the CSR build is only 3.6 ms at that size (§2.2). For a giant +component the Map churn is hundreds of thousands of allocations. ### F4 — `linksForEntity` allocates a fresh array + objects on every call — **MEDIUM** `stores/link-store.ts:134` `linksForEntity` returns a brand‑new `LinkEndpoint[]` -with a fresh object literal per incident link on **every call**. Hot callers: - -- `#buildFlatRenderEdges` (`graph-worker.ts:1411`) — per node, every commit (F1). -- `#seedFlatNodes`, `#buildEntityFanOut` in the worker. -- `community.ts` `topDegreeEntity` (`:168`), `collectLinkFeatures` (`:186`), - `linkSignatureKey` (`:279`) — several of these only need the _degree_ - (`.length`) yet allocate the full endpoint array. - -**Evidence:** part of the per‑commit O(N+E) cost and the community keying cost; -degree‑only callers pay a full allocation for a number. +with a fresh object literal per incident link on **every call**. It is called +per‑entity from many paths — `graph-worker.ts:538`, `:1332`, `:1411` +(`#buildFlatRenderEdges`, per node every commit — F1), `:2148`, `:2731`, `:2766`; +`community.ts:186`, `:279`; `cluster-feature-source.ts:69`; +`edge-aggregation.ts:713`. Two callers only want the **degree** yet still build +the whole endpoint array to read `.length`: `community.ts:168` and +`graph-worker.ts:1362`. + +**Evidence:** part of the per‑commit O(N+E) cost (§2.5) and the community keying +cost (§2.1); the degree‑only callers allocate an array of objects to obtain a +single number. ### F5 — Double type‑set keying per node during ingest — **MEDIUM** @@ -257,7 +264,7 @@ builds the key with `directTypeIdxs.items.join(",")` (`type-set-store.ts:112`). The peek exists only to snapshot the group’s `count` before insert for delta computation, but it fully constructs (and, via `getOrCreate`, creates) the group. -**Evidence:** peek+insert is a stable **~1.9×** the single‑keying cost (§2.1). +**Evidence:** peek+insert is a stable **~2.0×** the single‑keying cost (§2.1). ### F6 — Per‑frame allocation in `#emitPositions` — **MEDIUM** (code review; not isolated‑benchmarked) @@ -282,20 +289,22 @@ here from code review. A targeted bench should follow the fix. ### F7 — cola flat layout is O(N²) — **INFO** (validates the 200‑node cap) `layout/flat-layout.ts` builds a full N×N distance matrix -(`Calculator(...).DistanceMatrix()` + `Descent.createSquareMatrix`, `:163`–`:173`) -and runs `Descent.rungeKutta()` (O(N²)/step). Measured 0.41 s at 200 nodes (§2.4). +(`Calculator(...).DistanceMatrix()` `:163`–`:169` + `Descent.createSquareMatrix` +`:170`) and steps `Descent.rungeKutta()` (`:260`) — O(N²) per step. Measured +0.41 s at 200 nodes (§2.4). This is expected and correctly gated by `flatLayoutMaxNodes: 200`; **no action** beyond keeping the cap. ### F8 — `makeGrowableBuffer(resizable:false)` ignores `maxByteLength` — **LOW** -`buffers/growable-buffer.ts:40` returns a _fixed_ `SharedArrayBuffer` when -`resizable === false`, so `FlatGraphBuffer` can never grow in place — every -growth re‑allocates and memcpies. `ensureCapacity`’s "double the ceiling" -comment (`:158`) only affects the ignored `maxByteLength` argument for such -buffers. This is currently fine because `flatCapacityFor` (`graph-worker.ts:157`) -adds 1.5× slack so growth is amortized O(N) (§2.3), but the comment is misleading -and the amortization silently depends on the caller always over‑allocating. +`buffers/growable-buffer.ts` `makeGrowableBuffer` returns a _plain, fixed_ +`SharedArrayBuffer` (no `maxByteLength`) when `resizable === false` (`:45`–`:49`), +so `FlatGraphBuffer` can never grow in place — every growth in `ensureCapacity` +(`:153`) re‑allocates and memcpies (the class doc says as much, `:103`–`:105`). +The "double the ceiling" comment (`:158`) only helps _resizable_ buffers. This is +currently fine because `flatCapacityFor` (`graph-worker.ts:157`) adds 1.5× slack +so growth is amortized O(N) (§2.3), but the comment is misleading and the +amortization silently depends on the caller always over‑allocating. --- @@ -316,15 +325,16 @@ and the amortization silently depends on the caller always over‑allocating. `FLAT_LOUVAIN_LINGER_MS`). Apply the same idea to structure commits: during an ingest burst, coalesce multiple `INGEST_BATCH`es into a single `commitStructure` on a microtask/rAF boundary. Combined with fix 1 this - collapses the 21.8× streaming penalty toward the bulk cost. + collapses the 24.5× streaming penalty toward the bulk cost. -3. **Make `boundedLabelPropagation` and `buildInducedCsr` allocation‑free in the - inner loop (F3).** _Medium impact, medium effort._ Reuse one scores buffer - (e.g. a `Float64Array` indexed by label plus a "touched labels" list to reset - in O(touched)), and shuffle in place (Fisher–Yates on a persistent array) - instead of `[...indices]` per iteration. In `buildInducedCsr`, count degrees - first then fill parallel `Int32Array`/`Float32Array` neighbour/weight arrays, - dropping the 2·E `{neighbor, weight}` object churn. +3. **Make `boundedLabelPropagation` allocation‑free in the inner loop (F3).** + _Medium impact, medium effort._ Reuse one scores buffer (e.g. a `Float64Array` + indexed by label plus a "touched labels" list to reset in O(touched)), and + shuffle in place (Fisher–Yates on a persistent array) instead of `[...indices]` + per iteration. Secondary, lower value: `buildInducedCsr` can do a counting pass + to fill `offsets`, then a single pass writing straight into the `neighbors` + typed array, dropping the N intermediate `number[]` arrays and the `reduce` + scan. 4. **Give `LinkStore` a non‑allocating degree + iteration API (F4).** _Medium impact, low effort._ Add `degree(entityIdx): number` (return the adjacency @@ -375,7 +385,7 @@ parentheses. - **Buffer growth stays geometric / no per‑append realloc** (`buffers/position-buffer.test.ts`, `buffers/growable-buffer.test.ts`). Append many records into a small `FlatGraphBuffer` via `flatCapacityFor` and assert the - number of `republish` callbacks is O(log N), not O(N) — this guards the 18× + number of `republish` callbacks is O(log N), not O(N) — this guards the ~21× cliff (§2.3, F8). Assert `capacity >= count` headroom holds after each grow. - **`linksForEntity` allocation callers use degree** (`stores/link-store.test.ts` @@ -417,7 +427,7 @@ Run one file: ```bash cd apps/hash-frontend -../../node_modules/.bin/vitest bench --run \ +node ../../node_modules/vitest/vitest.mjs bench --run \ src/pages/shared/graph-visualizer-2/worker/core/commit-rebuild.bench.ts ``` @@ -425,7 +435,7 @@ Run them all: ```bash cd apps/hash-frontend -../../node_modules/.bin/vitest bench --run \ +node ../../node_modules/vitest/vitest.mjs bench --run \ src/pages/shared/graph-visualizer-2/worker ``` diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts index 69345520eda..b5259459c43 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts @@ -11,13 +11,10 @@ import type { EntityId } from "@blockprotocol/type-system"; const INITIAL_CAPACITY = 4096; /** - * Owns entity ID interning and per-entity columnar storage. + * Entity ID interning and per-entity columnar storage. * - * Column indices are kept in sync with the interner: each new - * entity gets a push on every column, so EntityIdx works as the - * index into all of them. The {@link EntityIdBuffer} join map is just one more such - * column, written the instant an EntityIdx is assigned, so it is always current with the - * interner (no separate mirror/sync pass). + * Column indices are kept in sync with the interner: each new entity + * gets a push on every column, so EntityIdx indexes into all of them. */ export class EntityStore { readonly #interner: Interner = new Interner(); @@ -28,18 +25,10 @@ export class EntityStore { readonly #labelIdx: Column = new Column(Int32Array, 4096); - /** - * Query ROOTS as one bit per {@link EntityIdx} (1 bit/entity, auto-growing). A set bit means the - * entity came back as a root of the current query; a clear bit means it is a FRONTIER node -- a - * fetched link endpoint that is not itself a root, rendered greyed-out until expanded. Add-only - * within a worker's life (roots only grow as the frontier expands; a fresh query rebuilds the - * worker, and with it this set). - */ + /** Query roots. Add-only: roots only grow as the frontier expands. */ readonly #roots: BitSet = BitSet.empty(INITIAL_CAPACITY); - /** EntityIdx→EntityId SharedArrayBuffer join map. The worker passes `republish` (fired - * only on the rare re-allocation) and publishes {@link entityIdMap} once; reads are on - * the main thread, on demand. */ + /** EntityIdx to EntityId shared buffer. */ readonly #entityIdMap: EntityIdBuffer; constructor(republish?: RepublishHandler) { @@ -50,16 +39,11 @@ export class EntityStore { return this.#interner.size; } - /** The EntityIdx→EntityId join map SharedArrayBuffer (the worker publishes it to the main thread). */ get entityIdMap(): EntityIdBuffer { return this.#entityIdMap; } - /** - * Try to insert an entity. If new, allocates column slots and records its EntityId in - * the join map (growing the map geometrically, like the columns, so per-insert writes - * amortise O(1), never a grow-by-one per entity). - */ + /** Insert an entity if not already present. Returns whether it was newly created. */ tryInsert(entityId: EntityId): [created: boolean, idx: EntityIdx] { const [created, idx] = this.#interner.tryIntern(entityId); if (created) { @@ -96,15 +80,12 @@ export class EntityStore { this.#labelIdx.set(entityIdx, labelIdx); } - /** Whether this entity is a query ROOT (vs a fetched-but-unexpanded FRONTIER node). O(1). */ + /** Whether this entity is a query root (vs frontier). */ isRoot(entityIdx: EntityIdx): boolean { return this.#roots.has(entityIdx); } - /** - * Promote an entity to a root. Returns whether it FLIPPED (was a frontier node), so the caller - * can recolour just that one record rather than re-styling the whole tier. - */ + /** Promote an entity to a root. Returns whether it flipped (was previously frontier). */ setRoot(entityIdx: EntityIdx): boolean { if (this.#roots.has(entityIdx)) { return false; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts index c25021d8475..2073f76fb5f 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts @@ -12,10 +12,9 @@ export interface LinkEndpoint { } /** - * Owns link storage: columnar arrays for left/right endpoints, - * link type, and the link's own entity index. Also tracks pending - * links whose endpoints haven't been ingested yet (for frontier - * detection). + * Link storage: columnar arrays for endpoints, link type, and + * entity index. Tracks pending links whose endpoints haven't + * been ingested yet. */ export class LinkStore { readonly #leftIdx: Column = new Column( @@ -122,7 +121,7 @@ export class LinkStore { return this.#typeIdx.get(linkIdx); } - /** The link's OWN entity index (a link is an entity), for resolving a picked edge. */ + /** The link's own entity index (a link is itself an entity). */ getEntityIdx(linkIdx: number): EntityIdx { return this.#entityIdIdx.get(linkIdx); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts index 0f52ff2a5c4..7bc7fa3aa0c 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts @@ -1,25 +1,9 @@ /** - * Per-entity property FEATURES for distinctive-feature cluster naming. + * Per-entity property features for cluster naming. * - * The worker holds entity property VALUES (shipped on {@link IngestEntity}) but never - * needs their full fidelity: naming a cluster only needs to know which entities SHARE a - * given `(property, value)`. So each scalar property value is reduced to a "feature" -- a - * `(baseUrl, formatted-value)` pair -- and interned to a small integer, exactly like the - * entity/type interners elsewhere. An entity then stores just the sorted list of its - * feature indices, and the namer ({@link nameClustersByDistinctiveFeatures}) tallies - * those across a cluster's members. - * - * "Scalars-plus": strings/numbers/booleans become a value feature; arrays collapse to a - * count summary and nested objects to a presence token (weaker signals, but they cost - * almost nothing and occasionally disambiguate). Property display TITLES are registered - * separately (from {@link PropertySchemaEntry}) so a label reads "Destination = ..." and - * not a raw base URL. - * - * Numbers and ISO dates ADDITIONALLY keep their RAW value (per entity, keyed by an interned - * property base URL) so the namer can bucket them into per-subdivision quantile RANGES - * ("Quantity 100–500"). An exact value feature is still produced too, so a low-cardinality - * number (a status `= 0`) keeps naming by its exact value while a high-cardinality one - * (every quantity distinct, so no exact value is ever common) is named by its range instead. + * Each scalar property value is reduced to a `(baseUrl, formatted-value)` pair + * and interned. Numbers and ISO dates additionally keep their raw value for + * quantile range bucketing. */ import { Interner } from "../collections/interner"; @@ -39,11 +23,7 @@ export type NumericKind = "number" | "date"; /** Cap a value's serialized length so a free-text property can't bloat the interner. */ const MAX_VALUE_CHARS = 64; -/** - * ISO-8601 date / datetime: `YYYY-MM-DD` with an optional time and zone. Deliberately - * strict so a plain numeric string or arbitrary text is NOT mistaken for a date (a far - * looser `Date.parse` would treat "1" or "May" as dates). - */ +/** Strict ISO-8601 date/datetime. Rejects bare numbers and partial strings that `Date.parse` accepts. */ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/u; @@ -73,9 +53,10 @@ function truncate(value: string): string { } /** - * Reduce a property value to its label display form, or undefined when it carries no - * usable signal (empty string, empty array, null). Strings are quoted; numbers/booleans - * are bare; arrays summarise to a count and nested objects to a presence token. + * Format a property value for display, or `undefined` when it carries no signal. + * + * Strings are quoted, numbers/booleans are bare, arrays become a count, + * nested objects become `"present"`. */ function formatFeatureValue(value: unknown): string | undefined { if (typeof value === "string") { @@ -99,9 +80,9 @@ function formatFeatureValue(value: unknown): string | undefined { } /** - * The raw numeric value (for range bucketing) of a property value, or undefined when it is - * not a finite number or an ISO date. Dates collapse to epoch milliseconds so numbers and - * dates share one ordered axis. + * Numeric value of a property, or `undefined` when not a finite number or ISO date. + * + * Dates collapse to epoch milliseconds. */ function numericReading(value: unknown): NumericReading | undefined { if (typeof value === "number") { @@ -114,10 +95,7 @@ function numericReading(value: unknown): NumericReading | undefined { return undefined; } -/** - * Title-case the slug of a property-type base URL (".../property-type//") as a - * fallback when no registered title is available, so a label never shows a raw URL. - */ +/** Title-case the slug from a property-type base URL as a fallback display title. */ function slugTitleFromBaseUrl(baseUrl: string): string { const slug = /\/property-type\/(?[^/]+)\/?$/.exec(baseUrl)?.groups ?.slug; @@ -143,12 +121,8 @@ export class PropertyStore { /** Parallel to the interner: per-key base URL and kind (number vs date). */ readonly #numericKeyBaseUrl: string[] = []; readonly #numericKeyKind: NumericKind[] = []; - /** - * Per-entity raw numeric readings as two parallel arrays (key indices + values), indexed - * by EntityIdx. Kept RAW (not interned) precisely because the namer buckets them into - * ranges computed from the live distribution of a subdivision's siblings -- a value's - * meaning ("low" vs "high") is relative, so it cannot be pre-interned like an exact value. - */ + // Raw (not interned): range bucketing depends on the live distribution of + // a cluster's members, so values can't be pre-interned. readonly #entityNumericKeys: (Int32Array | undefined)[] = []; readonly #entityNumericValues: (Float64Array | undefined)[] = []; @@ -167,9 +141,9 @@ export class PropertyStore { } /** - * Reduce an entity's properties to its interned scalar features AND its raw numeric/date - * readings. No-op when the entity has no labelable property (a link, or only empty/complex - * values). + * Extract scalar features and numeric readings from an entity's properties. + * + * No-op when the entity has no labelable property. */ ingest(entityIdx: EntityIdx, properties: PropertyObject | undefined): void { if (!properties) { @@ -215,12 +189,12 @@ export class PropertyStore { } } - /** An entity's feature indices, or undefined if it has none. */ + /** Sorted feature indices for an entity, or `undefined` if it has none. */ featuresOf(entityIdx: EntityIdx): Int32Array | undefined { return this.#entityFeatures[entityIdx]; } - /** The base URL, title, and display value a feature renders to in a label. */ + /** Resolve a feature index to its display label. */ describe(featureIdx: FeatureIdx): FeatureLabel | undefined { const info = this.#featureInfo[featureIdx]; if (!info) { @@ -233,22 +207,20 @@ export class PropertyStore { }; } - /** An entity's numeric-property key indices (parallel to {@link numericValuesOf}). */ + /** Numeric property key indices, parallel to {@link numericValuesOf}. */ numericKeysOf(entityIdx: EntityIdx): Int32Array | undefined { return this.#entityNumericKeys[entityIdx]; } - /** An entity's raw numeric values (numbers, or dates as epoch ms; date-keyed by kind). */ + /** Raw numeric values (numbers, or dates as epoch ms). */ numericValuesOf(entityIdx: EntityIdx): Float64Array | undefined { return this.#entityNumericValues[entityIdx]; } - /** The base URL of a numeric property key, or undefined if unknown. */ numericBaseUrl(keyIdx: NumericKeyIdx): string | undefined { return this.#numericKeyBaseUrl[keyIdx]; } - /** Whether a numeric property key reads as a plain number or a date. */ numericKind(keyIdx: NumericKeyIdx): NumericKind { return this.#numericKeyKind[keyIdx] ?? "number"; } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts index 3316fa35b99..2cd1b1af1f8 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts @@ -11,7 +11,7 @@ export interface TypeInfo { readonly idx: TypeIdx; readonly url: VersionedUrl; readonly title: string; - /** For a link type, its inverse (target -> source) title; used to label a reverse lane. */ + /** For a link type, the inverse (target -> source) title. */ readonly inverseTitle?: string; readonly icon?: string; readonly parentIdxs: readonly TypeIdx[]; @@ -28,11 +28,8 @@ export class TypeRegistry { readonly #interner: Interner = new Interner(); readonly #types: (TypeInfo | undefined)[] = []; /** - * Stable colour slot per type, assigned sorted-by-base-URL within each - * registration batch and never reassigned (append-only). A type's colour is - * derived from its slot, so it is identical across reloads (the slot depends - * on the URL, not on arrival order) and unchanged when a later batch -- a new - * page or a frontier expansion -- registers more types. + * Stable colour slot per type. Sorted by base URL within each batch and + * append-only, so a type's colour is deterministic across reloads. */ readonly #colorSlots: Map = new Map(); #nextColorSlot = 0; @@ -58,10 +55,8 @@ export class TypeRegistry { } /** - * Debug: one line per registered type, idx, title, resolved parents/roots. - * A parent that was interned (referenced via `allOf`) but never given a - * schema shows as `#(unreg)`, the signature of a missing ancestor type, - * which makes its descendants resolve to empty `rootIdxs`. + * One line per registered type. An interned parent without a schema + * shows as `#(unreg)`. */ debugDump(): string { const name = (idx: TypeIdx): string => @@ -119,14 +114,6 @@ export class TypeRegistry { } } - /** - * Assign a stable colour slot to each newly-registered type. Sorting the - * batch by base URL makes the slot order deterministic regardless of the order - * types arrived in this session, so the same type gets the same slot -- and so - * the same colour -- on every reload. Slots are append-only: types from an - * earlier batch keep theirs, so a later page or frontier expansion never - * re-colours what is already on screen. - */ #assignColorSlots(newlyRegistered: readonly TypeIdx[]): void { const sorted = [...newlyRegistered].sort((left, right) => { const leftUrl = extractBaseUrl(this.#types[left]!.url); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts index d9d4fda43e7..32220cdd206 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts @@ -6,15 +6,31 @@ import type { EntityIdx, TypeIdx, TypeSetIdx } from "../../ids"; import type { ReadonlySortedSet } from "../collections/readonly-sorted-set"; import type { TypeRegistry } from "./type-registry"; +/** + * A group of entities that share the exact same set of direct entity types. + * + * The cluster tree decides whether a group is large enough to be its own + * cluster ({@link isStandalone}) or should be merged into a type-ancestor + * cluster. {@link assignedClusterId} tracks which cluster currently owns + * this group's entities. + */ export class TypeSetGroup { readonly key: TypeSetKey; readonly idx: TypeSetIdx; readonly directTypeIdxs: ReadonlySortedSet; + /** Cluster ID used when this group is large enough to stand alone. */ readonly standaloneClusterId: ClusterId; #entityIdxs: EntityIdx[] = []; + /** Union of ancestor closures of all direct types. Used for merge-target lookup. */ #closure: BitSet; + /** Incremented on entity add; used for change detection. */ #version = 0; + /** + * The cluster that currently owns this group's entities: + * {@link standaloneClusterId} when standalone, or a merge target's ID when + * the group is too small. + */ #assignedClusterId: ClusterId; #isStandalone = false; @@ -69,7 +85,7 @@ export class TypeSetGroup { this.#version++; } - /** Recompute the closure from the type registry's ancestor closures. */ + /** Recompute the ancestor closure from the current type registry. */ recomputeClosure(types: TypeRegistry): void { let closure = BitSet.empty(types.size); From 0791385cd8214f185411efe94dd25742f8f61b5c Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:36:40 +0200 Subject: [PATCH 42/78] feat: code review --- apps/hash-frontend/package.json | 2 +- .../pages/shared/graph-visualizer-2/ids.ts | 19 +- .../shared/graph-visualizer-2/math/hash.ts | 23 +- .../graph-visualizer-2/render/clusters.ts | 8 +- .../shared/graph-visualizer-2/render/scene.ts | 16 +- .../render/worker-connection.ts | 48 +- .../graph-visualizer-2/worker/PERFORMANCE.md | 143 +++- .../worker/bench-fixtures.ts | 18 +- .../worker/buffers/growable-buffer.ts | 16 +- .../worker/collections/column.ts | 8 + .../worker/core/graph-worker.ts | 183 ++--- .../worker/csr-graph.test.ts | 6 +- .../graph-visualizer-2/worker/csr-graph.ts | 10 +- .../worker/entity-style.test.ts | 12 +- .../graph-visualizer-2/worker/entity-style.ts | 18 +- .../worker/geometry/edge-aggregation.ts | 112 +-- .../worker/geometry/edge-geometry.ts | 4 +- .../hierarchy/cluster-feature-source.ts | 30 +- .../worker/hierarchy/cluster-tree.ts | 78 +- .../worker/hierarchy/community.ts | 48 +- .../distinctive-cluster-label.test.ts | 10 +- .../hierarchy/distinctive-cluster-label.ts | 12 +- .../layout/community-layout-cost.bench.ts | 393 ++++++++++ .../worker/layout/community-layout-cost.md | 198 +++++ .../worker/layout/community-layout.ts | 100 ++- .../graph-visualizer-2/worker/protocol.ts | 10 +- .../entity.test.ts} | 43 +- .../graph-visualizer-2/worker/store/entity.ts | 99 +++ .../graph-visualizer-2/worker/store/link.ts | 155 ++++ .../property-store.ts => store/property.ts} | 107 +-- .../{stores => store}/type-registry.test.ts | 35 +- .../worker/store/type-registry.ts | 234 ++++++ .../type-set-store.ts => store/type-set.ts} | 74 +- .../worker/stores/entity-store.ts | 97 --- .../worker/stores/ingestion.bench.ts | 132 ---- .../worker/stores/link-store.ts | 163 ---- .../worker/stores/type-registry.ts | 238 ------ yarn.lock | 728 +++++++++++++++++- 38 files changed, 2506 insertions(+), 1124 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.md rename apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/{stores/entity-store.test.ts => store/entity.test.ts} (63%) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts rename apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/{stores/property-store.ts => store/property.ts} (60%) rename apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/{stores => store}/type-registry.test.ts (76%) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts rename apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/{stores/type-set-store.ts => store/type-set.ts} (62%) delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts diff --git a/apps/hash-frontend/package.json b/apps/hash-frontend/package.json index 80cdee5eb0a..4133e177837 100644 --- a/apps/hash-frontend/package.json +++ b/apps/hash-frontend/package.json @@ -181,7 +181,7 @@ "rimraf": "6.1.3", "sass": "1.93.2", "typescript": "5.9.3", - "vitest": "4.1.8", + "vitest": "4.1.9", "wait-on": "9.0.1", "webpack": "5.104.1" } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts index c55b6a843a1..6422ccef022 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/ids.ts @@ -9,21 +9,24 @@ import { Branded as make } from "./brand"; */ import type { Branded } from "./brand"; -export type EntityIdx = Branded; -export const EntityIdx = make(); +export type EntityIndex = Branded; +export const EntityIndex = make(); -export type LinkIdx = Branded; -export const LinkIdx = make(); +export type LinkId = Branded; +export const LinkId = make(); -export type TypeIdx = Branded; -export const TypeIdx = make(); +export type TypeId = Branded; +export const TypeId = make(); /** Sorted, comma-joined TypeIdx values. Canonical grouping key. */ export type TypeSetKey = Branded; export const TypeSetKey = make(); -export type TypeSetIdx = Branded; -export const TypeSetIdx = make(); +export type TypeSetId = Branded; +export const TypeSetId = make(); + +export type LabelId = Branded; +export const LabelId = make(); export type ClusterId = Branded; export const ClusterId = make(); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts index 9bb5e4db77d..ad872dc54f6 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/hash.ts @@ -62,17 +62,18 @@ export function murmur3(key: Uint8Array, seed: number = 0): number { let k1 = 0; - switch (tail.length) { - case 3: - k1 ^= tail[2]! << 16; // fallthrough - case 2: - k1 ^= tail[1]! << 8; // fallthrough - case 1: - k1 ^= tail[0]! << 0; - k1 = mul32(k1, c1); - k1 = rotl32(k1, 15); - k1 = mul32(k1, c2); - h1 ^= k1; + if (tail.length >= 3) { + k1 ^= tail[2]! << 16; + } + if (tail.length >= 2) { + k1 ^= tail[1]! << 8; + } + if (tail.length >= 1) { + k1 ^= tail[0]! << 0; + k1 = mul32(k1, c1); + k1 = rotl32(k1, 15); + k1 = mul32(k1, c2); + h1 ^= k1; } // finalization diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts index 1569ecab685..66991cad742 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts @@ -18,7 +18,7 @@ import { } from "../worker/buffers/position-buffer"; import type { PositionsFrame, RenderCluster, StructureFrame } from "../frames"; -import type { ClusterId, EntityIdx } from "../ids"; +import type { ClusterId, EntityIndex } from "../ids"; import type { ClusterReference } from "./worker-connection"; import type { Layer } from "@deck.gl/core"; @@ -129,7 +129,7 @@ export function clusterEntityLayers(config: { readonly positionTick: number; /** Highlighted entities (selection + ego); a leaf line whose endpoints aren't all in here * dims, in step with the dots. Empty = no selection, every line full. */ - readonly highlightedEntities: ReadonlySet; + readonly highlightedEntities: ReadonlySet; }): Layer[] { const { structure, positions, clusters, positionTick, highlightedEntities } = config; @@ -138,7 +138,9 @@ export function clusterEntityLayers(config: { // SAME lookup the dots' colours use, so a line dims exactly when its endpoints' dots do. const nodeHighlighted = (ref: ClusterReference, local: number): boolean => { const id = ref.nodeIds[local]; - return id !== undefined && highlightedEntities.has(Number(id) as EntityIdx); + return ( + id !== undefined && highlightedEntities.has(Number(id) as EntityIndex) + ); }; const clusterPositions = positions.clusterPositions; // Fan-out feeder endpoints are POSITIONAL (they ride the positions frame, keyed by leaf diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts index 78b7977d173..f9fbef711a3 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts @@ -31,7 +31,7 @@ import { nodeGeometry, selectionOverlayLayers } from "./selection"; import { leafTypeIconLayers, typeIconLayer } from "./type-icons"; import type { PositionsFrame, StructureFrame } from "../frames"; -import type { ClusterId, EntityIdx } from "../ids"; +import type { ClusterId, EntityIndex } from "../ids"; import type { EgoTarget } from "../worker/protocol"; import type { PlacedCluster } from "./clusters"; import type { Selection, SelectionGeometry } from "./selection"; @@ -368,7 +368,7 @@ export class Scene { #highlightVersion = 0; /** Highlighted entities (selection + visible ego), mirroring what was sent to the worker, so * the internal leaf-edge lines dim in step with the dots they connect. */ - #highlightedEntities: ReadonlySet = new Set(); + #highlightedEntities: ReadonlySet = new Set(); #hoveredEntity: EntityId | null = null; /** * The hovered highway's lane + the hovered point in WORLD space. Kept so the summary card tracks @@ -389,7 +389,7 @@ export class Scene { #selected: Selection | null = null; /** A selected link EDGE (the link's own EntityIdx): a pinned card + Open, no ring/dim (a link * isn't a node to focus). Tracked by re-finding its bezier each emit. Excludes #selected. */ - #selectedLink: EntityIdx | null = null; + #selectedLink: EntityIndex | null = null; /** The selected node's ego (neighbors' visible representatives: dots and/or bubbles). */ #egoTargets: EgoRef[] = []; @@ -1082,7 +1082,7 @@ export class Scene { // The link's EntityIdx for a pick on a FLAT-tier edge (there beziers.ids carries the link's // EntityIdx). Null otherwise -- including the hierarchical tier, where the same channel carries // an aggregate laneId instead (see #pickedHighwayLaneId). - #pickedLinkEntityIdx(info: PickingInfo): EntityIdx | null { + #pickedLinkEntityIdx(info: PickingInfo): EntityIndex | null { if ( info.layer?.id !== FLAT_EDGE_LAYER_ID || info.index < 0 || @@ -1091,7 +1091,9 @@ export class Scene { return null; } const id = this.#handle.getPositions()?.beziers.ids[info.index]; - return id === undefined || id === BEZIER_NO_LINK ? null : (id as EntityIdx); + return id === undefined || id === BEZIER_NO_LINK + ? null + : (id as EntityIndex); } // The aggregate lane id for a pick on a HIERARCHICAL-tier highway (there beziers.ids carries @@ -1157,7 +1159,7 @@ export class Scene { // Select a link edge: a pinned card (with Open) that tracks the link's midpoint -- no // ring/dim/ego (a link isn't a node to focus). Clears any node selection first (un-dims). - #selectLink(linkEntityIdx: EntityIdx): void { + #selectLink(linkEntityIdx: EntityIndex): void { this.#select(null); this.#selectedLink = linkEntityIdx; this.#emitSelection(); @@ -1305,7 +1307,7 @@ export class Scene { // World midpoint of a selected link's edge, by re-locating its bezier segment (segment order // changes per tick, but the link's EntityIdx is stable). null if the link isn't rendered. - #linkMidpoint(linkEntityIdx: EntityIdx): { x: number; y: number } | null { + #linkMidpoint(linkEntityIdx: EntityIndex): { x: number; y: number } | null { const positions = this.#handle.getPositions(); if (!positions) { return null; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts index e14f8a88a6f..b6e6ce7dd65 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts @@ -8,7 +8,7 @@ * The presentation subscribes and drives Deck.gl imperatively from these events, so * no React state ever holds per-frame data and the layer set is never rebuilt wholesale. */ -import { ClusterId, type EntityIdx } from "../ids"; +import { ClusterId, type EntityIndex } from "../ids"; import { FLAT_ENTITYIDX_BYTE_OFFSET, FLAT_HEADER_BYTES, @@ -101,12 +101,15 @@ export interface WorkerHandle { ): EntityId | undefined; /** Decode an EntityIdx straight to its EntityId via the id-map SAB -- a picked edge already * has the link's EntityIdx, so it skips the record read {@link resolveEntityId} does. */ - entityIdToId(entityIdx: EntityIdx): EntityId | undefined; + entityIdToId(entityIdx: EntityIndex): EntityId | undefined; /** * The raw EntityIdx (join key) for a record -- the integer {@link resolveEntityId} decodes. * Used to query a node's neighbors without re-deriving the buffer layout in the caller. */ - entityIdxAt(layoutId: ClusterId, recordIndex: number): EntityIdx | undefined; + entityIdxAt( + layoutId: ClusterId, + recordIndex: number, + ): EntityIndex | undefined; /** * Locate the wanted entityIdxs within a layout's buffer -> their current render indices, for * placing highlight neighbors. A hierarchical leaf maps via its (fixed) nodeIds; the flat @@ -114,16 +117,16 @@ export interface WorkerHandle { */ locateRecords( layoutId: ClusterId, - wanted: ReadonlySet, - ): Map; + wanted: ReadonlySet, + ): Map; /** Ask the worker for a selected node's ego (its neighbors' visible representatives). */ - queryEgo(entityIdx: EntityIdx): Promise; + queryEgo(entityIdx: EntityIndex): Promise; /** Ask the worker for the link entities aggregated by a highway lane (its `laneId`). */ - queryHighwayLinks(laneId: number): Promise; + queryHighwayLinks(laneId: number): Promise; /** Pin a hierarchical leaf open (with ancestors) regardless of zoom, or null to clear. */ setPinned(clusterId: ClusterId | null): void; /** Highlight entities (selection ego now, path later); empty restores full colour. */ - setHighlight(entityIdxs: readonly EntityIdx[]): void; + setHighlight(entityIdxs: readonly EntityIndex[]): void; ingestBatch(entities: readonly IngestEntity[]): void; sendViewport(viewport: ViewportRect): void; registerTypes( @@ -165,7 +168,7 @@ export class WorkerConnection implements WorkerHandle { #highwayRequestId = 0; readonly #highwayRequests = new Map< number, - (linkEntityIdxs: readonly EntityIdx[]) => void + (linkEntityIdxs: readonly EntityIndex[]) => void >(); #structureDirty = false; @@ -252,14 +255,17 @@ export class WorkerConnection implements WorkerHandle { : decodeEntityId(mapBytes, entityIdx); } - entityIdToId(entityIdx: EntityIdx): EntityId | undefined { + entityIdToId(entityIdx: EntityIndex): EntityId | undefined { const mapBytes = this.#entityIdMapBytes; return mapBytes === undefined ? undefined : decodeEntityId(mapBytes, entityIdx); } - entityIdxAt(layoutId: ClusterId, recordIndex: number): EntityIdx | undefined { + entityIdxAt( + layoutId: ClusterId, + recordIndex: number, + ): EntityIndex | undefined { const cluster = this.#clusters.get(layoutId); if (!cluster || recordIndex < 0) { return undefined; @@ -268,7 +274,7 @@ export class WorkerConnection implements WorkerHandle { // off the record -- the same join key resolveEntityId decodes. if (cluster.flatCapacity === undefined) { const idx = cluster.nodeIds[recordIndex]; - return idx === undefined ? undefined : (Number(idx) as EntityIdx); + return idx === undefined ? undefined : (Number(idx) as EntityIndex); } const records = new Uint32Array(cluster.versionView.buffer); const slot = @@ -276,14 +282,14 @@ export class WorkerConnection implements WorkerHandle { recordIndex * FLAT_RECORD_BYTES + FLAT_ENTITYIDX_BYTE_OFFSET) / 4; - return records[slot] as EntityIdx | undefined; + return records[slot] as EntityIndex | undefined; } locateRecords( layoutId: ClusterId, - wanted: ReadonlySet, - ): Map { - const result = new Map(); + wanted: ReadonlySet, + ): Map { + const result = new Map(); const cluster = this.#clusters.get(layoutId); if (!cluster || wanted.size === 0) { return result; @@ -295,7 +301,7 @@ export class WorkerConnection implements WorkerHandle { if (raw === undefined) { continue; } - const entityIdx = Number(raw) as EntityIdx; + const entityIdx = Number(raw) as EntityIndex; if (wanted.has(entityIdx)) { result.set(entityIdx, index); } @@ -311,7 +317,7 @@ export class WorkerConnection implements WorkerHandle { index * FLAT_RECORD_BYTES + FLAT_ENTITYIDX_BYTE_OFFSET) / 4 - ] as EntityIdx | undefined; + ] as EntityIndex | undefined; if (entityIdx !== undefined && wanted.has(entityIdx)) { result.set(entityIdx, index); } @@ -319,7 +325,7 @@ export class WorkerConnection implements WorkerHandle { return result; } - queryEgo(entityIdx: EntityIdx): Promise { + queryEgo(entityIdx: EntityIndex): Promise { this.#egoRequestId += 1; const requestId = this.#egoRequestId; const message: MainToWorkerMessage = { @@ -333,7 +339,7 @@ export class WorkerConnection implements WorkerHandle { }); } - queryHighwayLinks(laneId: number): Promise { + queryHighwayLinks(laneId: number): Promise { this.#highwayRequestId += 1; const requestId = this.#highwayRequestId; const message: MainToWorkerMessage = { @@ -352,7 +358,7 @@ export class WorkerConnection implements WorkerHandle { this.#worker.postMessage(message); } - setHighlight(entityIdxs: readonly EntityIdx[]): void { + setHighlight(entityIdxs: readonly EntityIndex[]): void { const message: MainToWorkerMessage = { type: "SET_HIGHLIGHT", entityIdxs, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md index 19264acdbbe..b441d3fe813 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md @@ -24,6 +24,11 @@ Headline findings: slack (`flatCapacityFor`) and `Column` doubles; both stay amortized O(N). A naïve grow‑to‑exact‑count path would be **~21× slower at 50k records**, so this is worth a regression test, not a fix. +- **The hierarchical tier repeats the same "rebuild everything" pattern.** Every + commit reconstructs `CutIndex` entity ownership in O(N) (F9) and a viewport LOD + change computes the visible cut twice (F10); the 8k‑node no‑op commit (3.2 ms, + §2.5) is the benchmarked symptom, with the mechanism confirmed by subsystem + code review. --- @@ -70,9 +75,12 @@ from anywhere; the `node …/vitest.mjs` form above is the portable one.) so treat absolute ms as order‑of‑magnitude. **The ratios (e.g. bulk vs streaming, once vs twice, presized vs grown) are stable** and are where the findings live. -- The heavy layout settle benches and the fresh‑worker commit benches use a - fixed small iteration count (`iterations: 6`) rather than a time budget, - because a single settle can take seconds. Their RME is reported per row. +- The heavy layout settle benches and the fresh‑worker bulk/streaming ingest + bench use a fixed small iteration count (`iterations: 6`, `warmupIterations: 1`) + rather than a time budget, because a single settle/build can take seconds; the + no‑op re‑commit benches run on Vitest’s default time budget (hundreds–thousands + of samples). RME is reported per row (the bulk ingest row is the noisiest at + ±30 %; everything else is ≤ ~15 %, most ≤ 3 %). - The `GraphWorker` runs its layout scheduler on `MessageChannel`s. A synchronous bench body never lets those macrotasks fire mid‑measurement, so scheduler ticks do not inflate individual samples; Vitest tears the process down at the end. @@ -291,9 +299,8 @@ here from code review. A targeted bench should follow the fix. `layout/flat-layout.ts` builds a full N×N distance matrix (`Calculator(...).DistanceMatrix()` `:163`–`:169` + `Descent.createSquareMatrix` `:170`) and steps `Descent.rungeKutta()` (`:260`) — O(N²) per step. Measured -0.41 s at 200 nodes (§2.4). -This is expected and correctly gated by `flatLayoutMaxNodes: 200`; **no action** -beyond keeping the cap. +~0.38 s at 200 nodes (§2.4). This is expected and correctly gated by +`flatLayoutMaxNodes: 200`; **no action** beyond keeping the cap. ### F8 — `makeGrowableBuffer(resizable:false)` ignores `maxByteLength` — **LOW** @@ -306,6 +313,71 @@ currently fine because `flatCapacityFor` (`graph-worker.ts:157`) adds 1.5× slac so growth is amortized O(N) (§2.3), but the comment is misleading and the amortization silently depends on the caller always over‑allocating. +### Additional findings from parallel subsystem exploration (code‑review) + +This investigation opened with four parallel subsystem deep‑dives (core/stores/ +buffers, layout, hierarchy/community, geometry). They confirmed the benchmarked +findings above (F1–F8) and surfaced the following **hierarchical‑tier and +per‑frame** issues that the benchmarks here reach only indirectly (via the 8k‑node +no‑op commit in §2.5 and the FA2 settle in §2.4). Refs below were verified against +`HEAD`; these are code‑review findings, not each independently benchmarked. + +#### F9 — `CutIndex` rebuilds entity→owner for the whole graph every hierarchical commit — **HIGH** + +`geometry/edge-aggregation.ts:152`–`188` (`CutIndex` ctor → `#walkTree`) builds an +`entityOwner` map over every entity in every collapsed subtree, constructed fresh +at `core/graph-worker.ts:1046` on **every** `commitStructure` with a viewport +(ingest, LOD change, pin, post‑naming recommit). O(N entities + C clusters) per +commit — the hierarchical analogue of F1 and the main component of the 8k‑node +no‑op (§2.5). _Fix:_ keep a persistent entity→owner map; diff only opened/closed +subtrees + newly‑ingested groups. + +#### F10 — `computeVisibleCut` runs twice per viewport LOD change — **MEDIUM** + +`handleViewport` computes the cut and probes `wouldChange` (`graph-worker.ts:810`), +then `commitStructure` recomputes the identical cut (`:981`). _Fix:_ pass the +precomputed cut into `commitStructure`, or cache it keyed by viewport + tree +version. + +#### F11 — `buildInducedCsr` scans the entire link store, not the induced subgraph — **MEDIUM** + +`csr-graph.ts:36` iterates `linkIdx = 0 .. links.count` filtering by membership, so +subdividing a small cluster still costs O(L*total). Synchronous on cluster open +(`hierarchy/community.ts` `subclusterByLinks`). \_Fix:* build adjacency by walking +the member set via `linksForEntity`/the adjacency index instead of all links. +(Note: §2.2’s `buildInducedCsr` bench feeds a subset‑sized `LinkStore`, so it does +_not_ expose this whole‑store scan — the cost only appears in the worker call path.) + +#### F12 — Per‑frame geometry amplifiers (extends F6) — **MEDIUM** + +Beyond F6’s ports/beziers/`Float32Array` rebuild: `BezierSegmentSink.snapshot()` +(`geometry/edge-geometry.ts:223`–`232`) does five typed‑array `.slice()` copies +every frame; `routeAround` (`edge-geometry.ts:1214`, up to 8 passes of +`worstObstacleOnPath` `:1125` over all rendered bubbles) runs per direct pair + per +highway group each tick; and `PortCache` misses on almost every moving tick because +its key embeds quantized positions (`geometry/bubble-ports.ts:454`). _Fix:_ +double‑buffer/SAB the bezier payload; spatial‑index obstacles + cache routed +polylines by quantized endpoints; split port _slotting_ (neighbor‑set) from port +_position_ so the cache hits during motion. + +#### F13 — Community + distinctive‑feature labeling is synchronous on cluster open — **MEDIUM** + +`labelAllCommunities` and the TF‑IDF `collectLinkFeatures` (`hierarchy/community.ts`) +run inline inside the `commitStructure` that opens a large leaf, and +`nameClustersByDistinctiveFeatures` then triggers a _second_ full commit +(`graph-worker.ts:2937`+) for a label‑only change. _Fix:_ defer labeling to the +existing job channel and add a label‑only structure‑emit path that skips +CutIndex/aggregation. + +Layout deep‑dive also confirmed two per‑tick costs worth tracking: FA2 settle +detection scans all nodes every iteration (`layout/community-layout.ts` +`#fa2IterStats`, HEAD `:521`–`:538`) and confined sub‑clusters run +O(`CONFINE_PASSES=16` × N²) overlap relaxation per write (`layout/cluster-layout.ts:53`, +`:333`–`:375`). It also noted the `forceEdgeAvoid` cost described in +`MANIFESTO.md:355` no longer exists — overlap is handled by WebCola `avoidOverlaps` + +- `#fitWithin`, so that doc line is stale. + --- ## 4. Recommended fixes (ordered by impact / effort) @@ -362,6 +434,26 @@ amortization silently depends on the caller always over‑allocating. over‑allocation, or have `ensureCapacity` itself apply geometric slack so amortization doesn’t depend on every caller remembering to. +8. **Incremental `CutIndex` + cut caching for the hierarchical tier (F9, F10).** + _High impact, medium effort._ Maintain a persistent entity→owner map updated + only for opened/closed subtrees and newly‑ingested groups instead of rebuilding + it over all entities each commit; and pass the cut computed in `handleViewport` + into `commitStructure` (or cache it by viewport + tree version) so LOD changes + don’t recompute it twice. This is the hierarchical counterpart to fixes 1–2. + +9. **Induced‑subgraph CSR + deferred labeling on cluster open (F11, F13).** + _Medium impact, medium effort._ Build the induced adjacency by walking the + cluster’s member set (via the adjacency index) rather than scanning the whole + link store, and move `labelAllCommunities`/distinctive‑feature naming off the + synchronous open path onto the existing job channel, with a label‑only emit that + skips CutIndex/aggregation. + +10. **Per‑frame geometry caching (F12).** _Medium impact, medium effort._ + Double‑buffer or SAB‑back the bezier payload to avoid `snapshot()`’s five copies + per frame; spatial‑index obstacles and cache routed polylines by quantized + endpoints; split port slotting from port position so `PortCache` hits during + motion. Add a targeted geometry bench once decoupled (see F6). + --- ## 5. Tests to add @@ -374,7 +466,7 @@ parentheses. rebuild the flat layout or re‑emit a structure frame with a new count. Concrete hooks: spy on `onStructureFrame`/`onLayoutMessage` and assert no `LAYOUT_CREATED`/`LAYOUT_DESTROYED`; assert the structure version is unchanged. - This is the direct guard for F1 and the 8 ms no‑op. + This is the direct guard for F1 and the ~8.5 ms no‑op. - **Streaming equals bulk in structural output** (`core/graph-worker.test.ts`). Ingesting a graph as one batch vs many batches must produce the same final @@ -389,9 +481,9 @@ parentheses. cliff (§2.3, F8). Assert `capacity >= count` headroom holds after each grow. - **`linksForEntity` allocation callers use degree** (`stores/link-store.test.ts` - - call‑site tests). Add `degree()` and assert it equals - `linksForEntity().length` for representative graphs, so the non‑allocating path - can’t silently diverge (F4). + plus call‑site tests). Add `degree()` and assert it equals + `linksForEntity().length` for representative graphs, so the non‑allocating path + can’t silently diverge (F4). - **Type‑set is keyed once per ingested node** (`core/graph-worker.test.ts` or a `TypeSetStore` spy). Assert `getOrCreate` (or `ReadonlySortedSet` construction) @@ -407,6 +499,20 @@ parentheses. emitted flat frame count) equals the number of ingested non‑link entities, so a future caching optimization of that method can’t drop or duplicate nodes. +- **`CutIndex` diff equals full rebuild** (`geometry/edge-aggregation.test.ts`). + After fix 8, assert an incrementally‑maintained entity→owner map is byte‑for‑byte + equal to a from‑scratch `CutIndex` across a sequence of ingest + LOD‑open/close + operations — the correctness guard for F9. + +- **`computeVisibleCut` runs once per LOD change** (`core/graph-worker.test.ts`). + Spy/count `computeVisibleCut` calls across a viewport change that crosses a LOD + threshold and assert it is computed once, not twice (F10). + +- **Induced CSR is independent of total link count** (`csr-graph.test.ts` or + `hierarchy/community.test.ts`). Build the same small cluster inside stores with + very different _total_ link counts and assert `subclusterByLinks` work (edge + count / timing budget) tracks the induced edges, not `links.count` (F11). + --- ## 6. Benchmark files added & how to run @@ -448,9 +554,14 @@ buffer, and geometry benches finish in seconds. - **ESLint:** clean (`eslint --report-unused-disable-directives`, exit 0) for all seven files. - **TypeScript:** clean — `tsc --noEmit` reports **no** errors in the added files. - (The app has ~34 pre‑existing `tsc` errors in unrelated files — `supply-chain/*`, - `slide-stack`, `math/hash.ts`, missing `recharts`/`@tanstack/react-table` — none - touched here.) -- No production code was changed; the only additions are the benchmark files and - `bench-fixtures.ts`. Pre‑existing local edits in `entry.ts`, `protocol.ts`, - `random.ts`, `random.test.ts` were left untouched. + (The app has 34 pre‑existing `tsc` errors in unrelated files, e.g. `supply-chain/*` + and modules with missing type declarations like `recharts` — none touched here.) +- No production code was changed by this investigation; the only additions are + the seven benchmark/fixture files and this report. Pre‑existing local edits in + `entry.ts`, `protocol.ts`, `random.ts`, `random.test.ts` were left untouched. +- **Line‑number baseline:** all `file:line` references are against committed + `HEAD`. Note that `layout/community-layout.ts` currently carries an _unrelated, + uncommitted_ profiler edit (an opt‑in `CommunityLayoutProfiler`, ~92 lines, not + authored here and left untouched) that shifts F2’s lines in the working tree + (`#rebuildMatrices` 251→310, `absorb` 359→424, `resolveEdges` 664→749). The + method names are the stable anchor. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts index 02448fc3ae8..bc2830b48bd 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts @@ -1,11 +1,11 @@ /** Deterministic synthetic-graph builders for benchmarks. */ import { entityIdFromComponents } from "@blockprotocol/type-system"; -import { EntityIdx, TypeSetIdx } from "../ids"; +import { EntityIndex, TypeSetId } from "../ids"; import { Column } from "./collections/column"; import { radiusForDegree } from "./entity-style"; import { mulberry32 } from "./random"; -import { LinkStore } from "./stores/link-store"; +import { LinkStore } from "./store/link"; import type { ForceEdge, ForceNode } from "./layout/force-simulation"; import type { IngestEntity, TypeSchemaEntry } from "./protocol"; @@ -184,27 +184,27 @@ export function buildForceGraph(shape: GraphShape): { /** Link store and entity index column. Entity indices are the contiguous range `[0, nodeCount)`. */ export function buildCommunityInputs(shape: GraphShape): { - readonly entityIdxs: Column; + readonly entityIdxs: Column; readonly links: LinkStore; } { - const entityIdxs = new Column( + const entityIdxs = new Column( Int32Array, Math.max(1, shape.nodeCount), ); for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { - entityIdxs.push(EntityIdx(nodeIndex)); + entityIdxs.push(EntityIndex(nodeIndex)); } const links = new LinkStore(); const pairs = buildEdgePairs(shape); - const linkTypeIdx = TypeSetIdx(0); + const linkTypeIdx = TypeSetId(0); for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) { const [left, right] = pairs[pairIndex]!; links.insert( - EntityIdx(left), - EntityIdx(right), + EntityIndex(left), + EntityIndex(right), linkTypeIdx, - EntityIdx(shape.nodeCount + pairIndex), + EntityIndex(shape.nodeCount + pairIndex), ); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts index 3e57cd92aa5..146e3d7ac3f 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts @@ -150,22 +150,24 @@ export abstract class GrowableBuffer { * copies the bytes across, re-binds the views, and re-publishes so the main thread * swaps to (and re-watches) the new buffer. */ - ensureCapacity(capacity: number): void { - const needed = this.headerBytes + capacity * this.recordBytes; - if (growBuffer(this.raw, needed)) { + ensureCapacity(needed: number): void { + const neededBytes = this.headerBytes + needed * this.recordBytes; + if (growBuffer(this.raw, neededBytes)) { return; } - // Double the ceiling so a resizable re-allocation can keep growing in place after. + // Double current capacity or use the requested amount, whichever is larger. + const growTo = Math.max(needed, this.capacity * 2); + const growToBytes = this.headerBytes + growTo * this.recordBytes; const next = makeGrowableBuffer( - needed, - Math.max(needed, this.raw.byteLength * 2), + growToBytes, + Math.max(growToBytes, this.raw.byteLength * 2), this.#resizable, ); new Uint8Array(next).set(new Uint8Array(this.raw)); this.raw = next; this.#version = new Int32Array(this.raw, 0, 1); this.bindRecordViews(this.raw); - this.#republish(this.raw, capacity); + this.#republish(this.raw, growTo); } /** Bump + notify the version counter so the main thread re-reads the buffer. */ diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts index 4d01bd53ad4..dbe0fd6b913 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts @@ -134,6 +134,14 @@ export class Column { return this.#view[idx]! as T; } + getOrDefault(idx: number): T { + if (idx < 0 || idx >= this.#length) { + return 0 as T; + } + + return this.#view[idx]! as T; + } + set(idx: number, value: T): void { if (idx < 0 || idx >= this.#length) { throw new RangeError( diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts index d2b70463972..3e10c00250e 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts @@ -40,11 +40,11 @@ import { createFlatLayout } from "../layout/flat-layout"; import { sharedBufferAvailable } from "../layout/force-simulation"; import { optimizeTopLevel } from "../layout/top-level-layout"; import { untangleLayout } from "../layout/untangle"; -import { EntityStore } from "../stores/entity-store"; -import { LinkStore } from "../stores/link-store"; -import { PropertyStore } from "../stores/property-store"; -import { TypeRegistry } from "../stores/type-registry"; -import { TypeSetStore } from "../stores/type-set-store"; +import { EntityStore } from "../store/entity"; +import { LinkStore } from "../store/link"; +import { PropertyStore } from "../store/property"; +import { TypeRegistry } from "../store/type-registry"; +import { TypeSetStore } from "../store/type-set"; import { layoutNeedsRebuild } from "./layout-reuse"; import { viewportAnchorWeight } from "./viewport-anchor"; @@ -61,7 +61,7 @@ import type { RenderFlatGraph, StructureFrame, } from "../../frames"; -import type { EntityIdx, TypeSetIdx, TypeSetKey, VizMode } from "../../ids"; +import type { EntityIndex, TypeSetId, TypeSetKey, VizMode } from "../../ids"; import type { RepublishHandler } from "../buffers/growable-buffer"; import type { Port } from "../geometry/bubble-ports"; import type { EdgeFrame } from "../geometry/edge-aggregation"; @@ -87,7 +87,7 @@ import type { PropertySchemaEntry, TypeSchemaEntry, } from "../protocol"; -import type { TypeSetGroup } from "../stores/type-set-store"; +import type { TypeSetGroup } from "../store/type-set"; import type { EntityId, VersionedUrl } from "@blockprotocol/type-system"; /** Above this node count, skip the D1 untangle (force result stands). */ @@ -129,7 +129,7 @@ interface FlatRenderEdge { readonly targetIdx: number; readonly color: Color; /** The link's own EntityIdx, so a picked edge resolves to its link entity. */ - readonly linkEntityIdx: EntityIdx; + readonly linkEntityIdx: EntityIndex; } const FAN_OUT_COLOR: Color = [...graphColors.fanOutEdge]; @@ -224,7 +224,7 @@ export class GraphWorker { #pinnedLeaf: ClusterId | undefined; /** Entities kept at full colour while a highlight is active (a selection's ego now, a path * later); everyone else dims. Empty = no highlight. Set via {@link setHighlight}. */ - #highlightedEntities = new Set(); + #highlightedEntities = new Set(); /** Set when an expand flips an already-rendered frontier node to a root. The flat tier restyles * every commit, but the hierarchical leaf path does not, so the worker re-applies styling after @@ -275,7 +275,7 @@ export class GraphWorker { /** Per-lane link-entity unions for the CURRENT structure, indexed by laneId. A merged * highway's lanes all share one union (the whole ribbon's links); set by #buildHighwayLanes, * read by highwayLinks on click. */ - #highwayLaneUnions: EntityIdx[][] = []; + #highwayLaneUnions: EntityIndex[][] = []; // MessageChannel-based simulation scheduler. readonly #schedulerChannel = new MessageChannel(); @@ -532,11 +532,11 @@ export class GraphWorker { * into. Neighbors not in view are omitted. The main thread highlights dots + bubbles from * this. O(degree); per selection, not per frame. */ - ego(entityIdx: EntityIdx): EgoTarget[] { + ego(entityIdx: EntityIndex): EgoTarget[] { const cutIndex = this.#cutIndex; const targets = new Map(); - for (const link of this.#links.linksForEntity(entityIdx)) { - const neighbor = link.otherIdx; + for (const link of this.#links.linksFor(entityIdx)) { + const neighbor = link.otherId; if (!cutIndex) { // Flat tier: no cut -- every entity is an individually-rendered dot. @@ -572,13 +572,13 @@ export class GraphWorker { */ insertNodeEntity( entity: IngestEntity, - ): { entityIdx: EntityIdx; groupKey: TypeSetKey } | undefined { - const [created, entityIdx] = this.#entities.tryInsert(entity.entityId); + ): { entityIdx: EntityIndex; groupKey: TypeSetKey } | undefined { + const [created, entityIdx] = this.#entities.insert(entity.entityId); // Apply root-ness even for an already-interned entity: an expand re-sends a frontier node as a // root, and this is what flips it. A flip of an already-rendered node needs a restyle the // commit alone won't do in the hierarchical tier (see restyleIfRootsFlipped). if (entity.isRoot) { - const flippedExisting = this.#entities.setRoot(entityIdx) && !created; + const flippedExisting = this.#entities.insertRoot(entityIdx) && !created; if (flippedExisting) { this.#rootFlipPending = true; } @@ -595,7 +595,7 @@ export class GraphWorker { const group = this.#typeSets.getOrCreate(directTypeIdxs, this.#types.size); group.addEntity(entityIdx); - this.#entities.setTypeGroup(entityIdx, group.idx); + this.#entities.setTypeSet(entityIdx, group.id); // Reduce the entity's properties to its interned features now, while ingesting, so a // later cluster-naming pass just tallies integers (see {@link PropertyStore}). this.#properties.ingest(entityIdx, entity.properties); @@ -609,13 +609,13 @@ export class GraphWorker { return; } - const [created, linkEntityIdx] = this.#entities.tryInsert(entity.entityId); + const [created, linkEntityIdx] = this.#entities.insert(entity.entityId); if (!created) { return; } - const leftIdx = this.#entities.tryGet(entity.linkData.leftEntityId) ?? -1; - const rightIdx = this.#entities.tryGet(entity.linkData.rightEntityId) ?? -1; + const leftIdx = this.#entities.lookup(entity.linkData.leftEntityId) ?? -1; + const rightIdx = this.#entities.lookup(entity.linkData.rightEntityId) ?? -1; const linkTypeIdxs = new ReadonlySortedSet( entity.entityTypeIds.map((url) => this.#types.intern(url)), @@ -626,18 +626,18 @@ export class GraphWorker { this.#types.size, ); - const linkIdx = this.#links.insert( + const linkId = this.#links.insert( leftIdx, rightIdx, - linkGroup.idx, + linkGroup.id, linkEntityIdx, ); if (leftIdx === -1) { - this.#links.addPending(entity.linkData.leftEntityId, linkIdx); + this.#links.addPending(entity.linkData.leftEntityId, linkId); } if (rightIdx === -1) { - this.#links.addPending(entity.linkData.rightEntityId, linkIdx); + this.#links.addPending(entity.linkData.rightEntityId, linkId); } } @@ -696,7 +696,7 @@ export class GraphWorker { * Used to snapshot counts before insert. */ #peekGroup(entity: IngestEntity): TypeSetGroup | undefined { - if (this.#entities.tryGet(entity.entityId) !== undefined) { + if (this.#entities.lookup(entity.entityId) !== undefined) { return undefined; // Already inserted, skip. } @@ -859,7 +859,7 @@ export class GraphWorker { * and everyone else dims, so the highlight pops. Empty restores full colour. Generic -- the * worker just dims the complement; the main thread decides what the set is. */ - setHighlight(entityIdxs: readonly EntityIdx[]): void { + setHighlight(entityIdxs: readonly EntityIndex[]): void { this.#highlightedEntities = new Set(entityIdxs); this.#applyHighlight(); } @@ -1172,10 +1172,10 @@ export class GraphWorker { * order. Link entities live in no type-set group, so iterating the groups yields * exactly the nodes. */ - #allNodeEntityIdxs(): EntityIdx[] { - const result: EntityIdx[] = []; + #allNodeEntityIdxs(): EntityIndex[] { + const result: EntityIndex[] = []; for (const group of this.#typeSets) { - for (const idx of group.entityIdxs) { + for (const idx of group.entities) { result.push(idx); } } @@ -1192,7 +1192,7 @@ export class GraphWorker { if (this.#entityIdMapPublished) { return; } - const map = this.#entities.entityIdMap; + const map = this.#entities.lookupBuffer; this.#onLayoutMessage?.({ type: "ENTITY_ID_MAP", buffer: map.raw, @@ -1207,7 +1207,7 @@ export class GraphWorker { * appears beside an already-placed neighbour (incremental absorb, LAYOUT-MODES * "Incremental loading"). Replaces the shared buffer (LAYOUT_DESTROYED + LAYOUT_CREATED). */ - #rebuildFlatLayout(entityIdxs: readonly EntityIdx[]): void { + #rebuildFlatLayout(entityIdxs: readonly EntityIndex[]): void { const previous = this.#forceLayouts.get(FLAT_LAYOUT_ID); const priorPositions = new Map(); if (previous) { @@ -1271,7 +1271,7 @@ export class GraphWorker { */ #absorbFlatNodes( layout: LayoutSimulation, - entityIdxs: readonly EntityIdx[], + entityIdxs: readonly EntityIndex[], ): void { const priorPositions = new Map(); for (const node of layout.nodes) { @@ -1309,7 +1309,7 @@ export class GraphWorker { * absorbs streamed nodes instead of reshuffling. */ #seedFlatNodes( - entityIdxs: readonly EntityIdx[], + entityIdxs: readonly EntityIndex[], priorPositions: ReadonlyMap, ): ForceNode[] { const goldenAngle = Math.PI * (3 - Math.sqrt(5)); @@ -1329,8 +1329,8 @@ export class GraphWorker { if (placed.has(idx)) { continue; } - for (const link of this.#links.linksForEntity(idx)) { - const neighbour = placed.get(link.otherIdx as number); + for (const link of this.#links.linksFor(idx)) { + const neighbour = placed.get(link.otherId as number); if (neighbour) { const angle = idx * goldenAngle; placed.set(idx, [ @@ -1359,7 +1359,7 @@ export class GraphWorker { return entityIdxs.map((idx) => { const position = placed.get(idx)!; - const degree = this.#links.linksForEntity(idx).length; + const degree = this.#links.linksFor(idx).length; return { id: String(idx), x: position[0], @@ -1375,10 +1375,10 @@ export class GraphWorker { * recolours in place without a structure round-trip. */ #writeFlatStyle(layout: LayoutSimulation, buffer: FlatGraphBuffer): void { - const colorByGroup = new Map(); + const colorByGroup = new Map(); for (let idx = 0; idx < layout.nodes.length; idx++) { const node = layout.nodes[idx]!; - const entityIdx = Number(node.id) as EntityIdx; + const entityIdx = Number(node.id) as EntityIndex; buffer.setRadius(idx, node.radius); const base = this.#colorForEntity(entityIdx, colorByGroup); const dimmed = @@ -1404,24 +1404,24 @@ export class GraphWorker { for (let idx = 0; idx < layout.nodeIds.length; idx++) { localOf.set(Number(layout.nodeIds[idx]), idx); } - const colorCache = new Map(); + const colorCache = new Map(); const seenLinks = new Set(); const edges: FlatRenderEdge[] = []; for (const [entityIdx, sourceIdx] of localOf) { - for (const link of this.#links.linksForEntity(entityIdx as EntityIdx)) { - if (seenLinks.has(link.linkIdx)) { + for (const link of this.#links.linksFor(entityIdx as EntityIndex)) { + if (seenLinks.has(link.linkId)) { continue; } - const targetIdx = localOf.get(link.otherIdx as number); + const targetIdx = localOf.get(link.otherId as number); if (targetIdx === undefined) { continue; } - seenLinks.add(link.linkIdx); + seenLinks.add(link.linkId); edges.push({ sourceIdx: link.direction === "out" ? sourceIdx : targetIdx, targetIdx: link.direction === "out" ? targetIdx : sourceIdx, - color: this.#edgeColorForTypeGroup(link.typeSetIdx, colorCache), - linkEntityIdx: this.#links.getEntityIdx(link.linkIdx), + color: this.#edgeColorForTypeGroup(link.typeSetId, colorCache), + linkEntityIdx: this.#links.getEntityIndex(link.linkId), }); } } @@ -1483,8 +1483,8 @@ export class GraphWorker { const highlighted = this.#highlightedEntities; const full = highlighted.size === 0 || - (highlighted.has(Number(source.id) as EntityIdx) && - highlighted.has(Number(target.id) as EntityIdx)); + (highlighted.has(Number(source.id) as EntityIndex) && + highlighted.has(Number(target.id) as EntityIndex)); const color = full ? edge.color : dimColor(edge.color); sink.push( { @@ -1517,12 +1517,12 @@ export class GraphWorker { } /** Hierarchy-aware colour for an entity, cached per type-set group. */ - #colorForEntity(entityIdx: EntityIdx, cache: Map): Color { + #colorForEntity(entityIdx: EntityIndex, cache: Map): Color { // A frontier node (fetched, not yet expanded) reads greyed-out, whatever its type. if (!this.#entities.isRoot(entityIdx)) { return FRONTIER_COLOR; } - const groupIdx = this.#entities.getTypeGroup(entityIdx); + const groupIdx = this.#entities.getTypeSet(entityIdx); if (groupIdx === -1) { return colorForType(undefined, this.#types); } @@ -1531,17 +1531,14 @@ export class GraphWorker { /** Hierarchy-aware NODE colour for a type-set group, cached. Keys off the type's ROOT (a family * shares a hue) -- see {@link colorForType}. */ - #colorForTypeGroup( - groupIdx: TypeSetIdx, - cache: Map, - ): Color { + #colorForTypeGroup(groupIdx: TypeSetId, cache: Map): Color { const cached = cache.get(groupIdx); if (cached) { return cached; } - const group = this.#typeSets.getByIdx(groupIdx); + const group = this.#typeSets.getById(groupIdx); const primary = group - ? primaryTypeOfSet(group.directTypeIdxs, this.#types) + ? primaryTypeOfSet(group.directTypeIds, this.#types) : undefined; const color = colorForType(primary, this.#types); cache.set(groupIdx, color); @@ -1552,16 +1549,16 @@ export class GraphWorker { * slot, NOT its root -- every link type shares the `Link` root, so root-colouring collapses all * edges to one hue (see {@link edgeColorForType}). */ #edgeColorForTypeGroup( - groupIdx: TypeSetIdx, - cache: Map, + groupIdx: TypeSetId, + cache: Map, ): Color { const cached = cache.get(groupIdx); if (cached) { return cached; } - const group = this.#typeSets.getByIdx(groupIdx); + const group = this.#typeSets.getById(groupIdx); const primary = group - ? primaryTypeOfSet(group.directTypeIdxs, this.#types) + ? primaryTypeOfSet(group.directTypeIds, this.#types) : undefined; const color = edgeColorForType(primary, this.#types); cache.set(groupIdx, color); @@ -1718,7 +1715,7 @@ export class GraphWorker { const key = sourceContainers.length === 0 && targetContainers.length === 0 ? (edge.visualKey as string) - : `hw:${outerSource}:${outerTarget}:${edge.typeSetIdx ?? "roll"}:${edge.direction}`; + : `hw:${outerSource}:${outerTarget}:${edge.typeSetId ?? "roll"}:${edge.direction}`; const list = groups.get(key); if (list) { list.push(idx); @@ -1728,9 +1725,9 @@ export class GraphWorker { } const summaries: HighwayLaneSummary[] = edges.map(() => placeholder); - const unions: EntityIdx[][] = edges.map(() => []); + const unions: EntityIndex[][] = edges.map(() => []); for (const laneIdxs of groups.values()) { - const union = new Set(); + const union = new Set(); let typeId: VersionedUrl | null = null; let typeLabel = ""; let direction: HighwayLaneSummary["direction"] = "both"; @@ -1772,7 +1769,7 @@ export class GraphWorker { * not just the representative lane's). Precomputed per structure by {@link #buildHighwayLanes}; * empty for an out-of-range / non-aggregate id. */ - highwayLinks(laneId: number): EntityIdx[] { + highwayLinks(laneId: number): EntityIndex[] { return laneId >= 0 && laneId < this.#highwayLaneUnions.length ? [...(this.#highwayLaneUnions[laneId] ?? [])] : []; @@ -1908,6 +1905,7 @@ export class GraphWorker { : text.includes("\n") ? `${text}\n(${cluster.count})` : `${text} (${cluster.count})`; + return { id: cluster.id, color: colorForCluster(cluster, this.#types), @@ -1918,8 +1916,8 @@ export class GraphWorker { frontierCount: frontierIdxs.length, ...(allFrontier ? { - frontierEntityIds: frontierIdxs.map((idx) => - this.#entities.getEntityId(idx), + frontierEntityIds: frontierIdxs.map( + (idx) => this.#entities.get(idx)!, ), } : {}), @@ -1931,8 +1929,8 @@ export class GraphWorker { * from a direct index column or, for a type-keyed cluster, the union of its * type-set groups (see {@link ClusterNode.membership}). */ - #frontierMembers(cluster: ClusterNode): EntityIdx[] { - const frontier: EntityIdx[] = []; + #frontierMembers(cluster: ClusterNode): EntityIndex[] { + const frontier: EntityIndex[] = []; const { membership } = cluster; if (membership.source === "groups") { for (const key of membership.keys) { @@ -1940,7 +1938,7 @@ export class GraphWorker { if (!group) { continue; } - for (const entityIdx of group.entityIdxs) { + for (const entityIdx of group.entities) { if (!this.#entities.isRoot(entityIdx)) { frontier.push(entityIdx); } @@ -2026,7 +2024,7 @@ export class GraphWorker { const highlighted = this.#highlightedEntities; const active = highlighted.size > 0; for (let idx = 0; idx < layout.nodeIds.length; idx++) { - const entityIdx = Number(layout.nodeIds[idx]) as EntityIdx; + const entityIdx = Number(layout.nodeIds[idx]) as EntityIndex; // A frontier node reads greyed-out, overriding the cluster colour and the focus dim. if (!this.#entities.isRoot(entityIdx)) { layout.setNodeColor(idx, FRONTIER_COLOR); @@ -2139,14 +2137,14 @@ export class GraphWorker { let connectivityChanged = false; const fanOut: number[] = []; for (const node of layout.nodes) { - const entityIdx = Number(node.id) as EntityIdx; + const entityIdx = Number(node.id) as EntityIndex; const localIdx = localOf.get(entityIdx)!; const seenTargets = new Set(); let sumX = 0; let sumY = 0; let exitCount = 0; - for (const link of this.#links.linksForEntity(entityIdx)) { - const otherOwner = cutIndex.ownerOf(link.otherIdx as number); + for (const link of this.#links.linksFor(entityIdx)) { + const otherOwner = cutIndex.ownerOf(link.otherId as number); if ( !otherOwner || otherOwner === leafId || @@ -2670,21 +2668,21 @@ export class GraphWorker { }); } - #collectEntityIdxsForCluster(cluster: ClusterNode): EntityIdx[] { + #collectEntityIdxsForCluster(cluster: ClusterNode): EntityIndex[] { if (cluster.membership.source === "direct") { const view = cluster.membership.members.subarray(); - const result: EntityIdx[] = []; + const result: EntityIndex[] = []; for (const idx of view) { result.push(idx); } return result; } - const result: EntityIdx[] = []; + const result: EntityIndex[] = []; for (const key of cluster.membership.keys) { const group = this.#typeSets.get(key); if (group) { - for (const idx of group.entityIdxs) { + for (const idx of group.entities) { result.push(idx); } } @@ -2712,7 +2710,7 @@ export class GraphWorker { #buildClusterEdges(children: readonly ClusterNode[]): ForceEdge[] { // Build entityIdx -> childId lookup once. const entityToChild = new Map(); - const childEntityIdxs = new Map(); + const childEntityIdxs = new Map(); for (const child of children) { const entityIdxs = this.#collectEntityIdxsForCluster(child); childEntityIdxs.set(child.id, entityIdxs); @@ -2728,9 +2726,9 @@ export class GraphWorker { for (const child of children) { const entityIdxs = childEntityIdxs.get(child.id)!; for (const entityIdx of entityIdxs) { - const links = this.#links.linksForEntity(entityIdx); + const links = this.#links.linksFor(entityIdx); for (const link of links) { - const otherChildId = entityToChild.get(link.otherIdx); + const otherChildId = entityToChild.get(link.otherId); if (otherChildId === undefined || otherChildId === child.id) { continue; } @@ -2752,7 +2750,10 @@ export class GraphWorker { return edges; } - #buildEntityEdges(entityIdxs: EntityIdx[], nodes: ForceNode[]): ForceEdge[] { + #buildEntityEdges( + entityIdxs: EntityIndex[], + nodes: ForceNode[], + ): ForceEdge[] { const memberSet = new Set(entityIdxs); const idxToNodeId = new Map(); for (let idx = 0; idx < entityIdxs.length; idx++) { @@ -2763,13 +2764,13 @@ export class GraphWorker { const seen = new Set(); for (const entityIdx of entityIdxs) { - const links = this.#links.linksForEntity(entityIdx); + const links = this.#links.linksFor(entityIdx); for (const link of links) { - if (!memberSet.has(link.otherIdx)) { + if (!memberSet.has(link.otherId)) { continue; } const sourceId = idxToNodeId.get(entityIdx)!; - const targetId = idxToNodeId.get(link.otherIdx)!; + const targetId = idxToNodeId.get(link.otherId)!; const pairKey = sourceId < targetId ? `${sourceId}|${targetId}` @@ -2856,8 +2857,8 @@ export class GraphWorker { for (const key of node.membership.keys) { const group = this.#typeSets.get(key); if (group) { - for (const idx of group.entityIdxs) { - entityIds.push(this.#entities.getEntityId(idx)); + for (const idx of group.entities) { + entityIds.push(this.#entities.get(idx)!); } } } @@ -2867,7 +2868,7 @@ export class GraphWorker { const members = node.membership.members.subarray(); const entityIds = Array.from({ length: members.length }); for (let idx = 0; idx < members.length; idx++) { - entityIds[idx] = this.#entities.getEntityId(members.get(idx)); + entityIds[idx] = this.#entities.get(members.get(idx))!; } return entityIds; @@ -2894,7 +2895,7 @@ export class GraphWorker { const assignments = clusters.map((embeddingCluster) => { const memberIdxs = new Int32Array(embeddingCluster.entityIds.length); for (let idx = 0; idx < embeddingCluster.entityIds.length; idx++) { - const entityIdx = this.#entities.tryGet( + const entityIdx = this.#entities.lookup( embeddingCluster.entityIds[idx]! as EntityId, ); if (entityIdx !== undefined) { @@ -2957,16 +2958,16 @@ export class GraphWorker { #resolvePendingLinks( entityId: IngestEntity["entityId"], - entityIdx: EntityIdx, + entityIdx: EntityIndex, ): void { const pending = this.#links.takePending(entityId); if (!pending) { return; } - for (const linkIdx of pending) { - this.#links.resolveEndpoint(linkIdx, "left", entityIdx); - this.#links.resolveEndpoint(linkIdx, "right", entityIdx); + for (const linkId of pending) { + this.#links.resolveEndpoint(linkId, "left", entityIdx); + this.#links.resolveEndpoint(linkId, "right", entityIdx); } } } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts index 79433862b61..3cdb7f0d724 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.test.ts @@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest"; import { Column } from "./collections/column"; import { connectedComponents } from "./csr-graph"; -import type { EntityIdx } from "../ids"; +import type { EntityIndex } from "../ids"; import type { CsrGraph } from "./csr-graph"; /** Build a CsrGraph from an undirected adjacency list keyed by local index. */ const csrFrom = (adjacency: number[][]): CsrGraph => { - const nodeIds = new Column( + const nodeIds = new Column( Int32Array, Math.max(1, adjacency.length), ); for (let index = 0; index < adjacency.length; index++) { - nodeIds.push(index as EntityIdx); + nodeIds.push(index as EntityIndex); } const offsets = new Int32Array(adjacency.length + 1); const flat: number[] = []; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts index b7ff57e55ab..0045af7c394 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/csr-graph.ts @@ -2,12 +2,12 @@ import { BitSet } from "./collections/bitset"; -import type { EntityIdx } from "../ids"; +import type { EntityIndex } from "../ids"; import type { Column } from "./collections/column"; -import type { LinkStore } from "./stores/link-store"; +import type { LinkStore } from "./store/link"; export interface CsrGraph { - readonly nodeIds: Column; + readonly nodeIds: Column; /** Maps local index -> offset into neighbors/weights. Length = nodeIds.length + 1. */ readonly offsets: Int32Array; readonly neighbors: Int32Array; @@ -20,10 +20,10 @@ export interface CsrGraph { * Links with an endpoint outside the set, or self-loops, are dropped. */ export function buildInducedCsr( - entityIdxs: Column, + entityIdxs: Column, links: LinkStore, ): CsrGraph { - const localIndex = new Map(); + const localIndex = new Map(); for (let idx = 0; idx < entityIdxs.length; idx++) { localIndex.set(entityIdxs.get(idx), idx); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts index 5a5a8419327..8fcaa63b1c7 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.test.ts @@ -6,9 +6,9 @@ import { primaryTypeOfSet, radiusForDegree, } from "./entity-style"; -import { TypeRegistry } from "./stores/type-registry"; +import { TypeRegistry } from "./store/type-registry"; -import type { TypeIdx } from "../ids"; +import type { TypeId } from "../ids"; import type { TypeSchemaEntry } from "./protocol"; import type { VersionedUrl } from "@blockprotocol/type-system"; @@ -40,10 +40,10 @@ function hueOf(color: readonly number[]): number { function buildRegistry(): { registry: TypeRegistry; - company: TypeIdx; - customer: TypeIdx; - supplier: TypeIdx; - person: TypeIdx; + company: TypeId; + customer: TypeId; + supplier: TypeId; + person: TypeId; } { const company = url("company"); const customer = url("customer"); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts index 98177f279ed..bee29afdb49 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entity-style.ts @@ -13,8 +13,8 @@ import { murmur3StringUnit } from "../math/hash"; import { graphColors } from "../visual-style"; import type { Color } from "../frames"; -import type { TypeIdx } from "../ids"; -import type { TypeRegistry } from "./stores/type-registry"; +import type { TypeId } from "../ids"; +import type { TypeRegistry } from "./store/type-registry"; /** Hue step between successive colour slots (degrees). */ const GOLDEN_ANGLE_DEG = 137.508; @@ -41,7 +41,7 @@ const EDGE_NEUTRAL: Color = [...graphColors.fallbackEntity]; export const FRONTIER_COLOR: Color = [...graphColors.frontier]; function slotHue( - typeIdx: TypeIdx | undefined, + typeIdx: TypeId | undefined, types: TypeRegistry, ): number | undefined { if (typeIdx === undefined) { @@ -56,10 +56,10 @@ function slotHue( * by the smallest idx. */ export function primaryTypeOfSet( - typeIdxs: Iterable, + typeIdxs: Iterable, types: TypeRegistry, -): TypeIdx | undefined { - let best: TypeIdx | undefined; +): TypeId | undefined { + let best: TypeId | undefined; let bestDepth = -1; for (const typeIdx of typeIdxs) { const depth = types.get(typeIdx)?.depth ?? 0; @@ -77,7 +77,7 @@ export function primaryTypeOfSet( /** Colour for a type. Falls back to neutral grey when the type or its root is unknown. */ export function colorForType( - typeIdx: TypeIdx | undefined, + typeIdx: TypeId | undefined, types: TypeRegistry, ): Color { if (typeIdx === undefined) { @@ -87,7 +87,7 @@ export function colorForType( if (info === undefined) { return NEUTRAL; } - const hue = slotHue(info.rootIdxs[0], types); + const hue = slotHue(info.rootIds[0], types); if (hue === undefined) { return NEUTRAL; } @@ -115,7 +115,7 @@ export function colorForType( * all link types share a single root. */ export function edgeColorForType( - typeIdx: TypeIdx | undefined, + typeIdx: TypeId | undefined, types: TypeRegistry, ): Color { const hue = slotHue(typeIdx, types); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts index 8ab706587dd..8590f048ca5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts @@ -24,12 +24,12 @@ import { edgeColorForType, primaryTypeOfSet } from "../entity-style"; import type { VizConfig } from "../../config"; import type { Color } from "../../frames"; -import type { ClusterId, EntityIdx, TypeSetIdx } from "../../ids"; +import type { ClusterId, EntityIndex, TypeSetId } from "../../ids"; import type { ClusterNode, ClusterTree } from "../hierarchy/cluster-tree"; import type { LodItem } from "../hierarchy/lod"; -import type { LinkStore } from "../stores/link-store"; -import type { TypeRegistry } from "../stores/type-registry"; -import type { TypeSetGroup, TypeSetStore } from "../stores/type-set-store"; +import type { LinkStore } from "../store/link"; +import type { TypeRegistry } from "../store/type-registry"; +import type { TypeSetGroup, TypeSetStore } from "../store/type-set"; import type { VersionedUrl } from "@blockprotocol/type-system"; // Color / width / label helpers @@ -47,7 +47,7 @@ export function typeLabelForGroup( } const titles: string[] = []; - for (const typeIdx of group.directTypeIdxs) { + for (const typeIdx of group.directTypeIds) { const info = types.get(typeIdx); if (info) { titles.push(info.title); @@ -72,7 +72,7 @@ export function typeUrlForGroup( } let only: VersionedUrl | null = null; let seen = 0; - for (const typeIdx of group.directTypeIdxs) { + for (const typeIdx of group.directTypeIds) { const url = types.getUrl(typeIdx); if (url === undefined) { continue; @@ -100,7 +100,7 @@ export function inverseLabelForGroup( } const titles: string[] = []; - for (const typeIdx of group.directTypeIdxs) { + for (const typeIdx of group.directTypeIds) { const info = types.get(typeIdx); if (info) { titles.push(info.inverseTitle ?? info.title); @@ -126,7 +126,7 @@ function collectEntityOwnership( for (const key of node.membership.keys) { const group = typeSets.get(key); if (group) { - for (const idx of group.entityIdxs) { + for (const idx of group.entities) { result.set(idx as number, ownerId); } } @@ -294,7 +294,7 @@ export function makePairKey( // Internal mutable aggregation types interface TypeAggregation { - readonly typeSetIdx: TypeSetIdx; + readonly typeSetId: TypeSetId; /** * Links flowing from the lower-sorted cluster ID to the higher one, * matching PairKey's sort order. @@ -307,9 +307,9 @@ interface TypeAggregation { * the count (added on +1, removed on -1), so a clicked highway lane can * resolve, on demand, to the exact set of links it aggregates. */ - readonly forwardLinks: Set; + readonly forwardLinks: Set; /** The link entities counted in `reverseCount` (mirror of `forwardLinks`). */ - readonly reverseLinks: Set; + readonly reverseLinks: Set; } interface MutablePairAggregation { @@ -320,11 +320,11 @@ interface MutablePairAggregation { } interface StoredIndividualEdge { - readonly linkIdx: number; + readonly linkId: number; readonly ownerClusterId: ClusterId; readonly leftEntityIdx: number; readonly rightEntityIdx: number; - readonly typeSetIdx: TypeSetIdx; + readonly typeSetId: TypeSetId; } // Visual edge types (spec 6.2) @@ -353,7 +353,7 @@ export interface AggregatedVisualEdge { readonly source: ClusterEndpointRef; readonly target: ClusterEndpointRef; readonly pairKey: PairKey; - readonly typeSetIdx: TypeSetIdx | undefined; + readonly typeSetId: TypeSetId | undefined; /** * The lane's single link type as a VersionedUrl, when it has exactly one (a lane is single-type * by definition). `null` for a multi-type rollup (`collapsed`). The main thread resolves the @@ -381,7 +381,7 @@ export interface AggregatedVisualEdge { * forward links, a reverse lane the reverse, a collapsed/"both" lane the * union of both. Lets a clicked highway resolve to its underlying links. */ - readonly linkEntityIdxs: readonly EntityIdx[]; + readonly linkEntityIdxs: readonly EntityIndex[]; } export interface IndividualVisualEdge { @@ -389,8 +389,8 @@ export interface IndividualVisualEdge { readonly visualKey: VisualEdgeKey; readonly source: EntityEndpointRef; readonly target: EntityEndpointRef; - readonly linkIdx: number; - readonly typeSetIdx: TypeSetIdx; + readonly linkId: number; + readonly typeSetId: TypeSetId; readonly count: 1; readonly color: Color; readonly widthWorld: number; @@ -419,7 +419,7 @@ function explodePair( const typeAggs = [...pair.byType.values()].sort( (a, b) => b.forwardCount + b.reverseCount - (a.forwardCount + a.reverseCount) || - (a.typeSetIdx as number) - (b.typeSetIdx as number), + (a.typeSetId as number) - (b.typeSetId as number), ); const source: ClusterEndpointRef = { kind: "cluster", id: pair.sourceId }; @@ -427,7 +427,7 @@ function explodePair( if (typeAggs.length > config.maxParallelEdgeTypes) { // A collapsed/"both" lane carries the union of both directions' links. - const collapsedLinks: EntityIdx[] = []; + const collapsedLinks: EntityIndex[] = []; for (const agg of typeAggs) { collapsedLinks.push(...agg.forwardLinks, ...agg.reverseLinks); } @@ -438,7 +438,7 @@ function explodePair( source, target, pairKey, - typeSetIdx: undefined, + typeSetId: undefined, typeId: null, direction: "both", collapsed: true, @@ -459,12 +459,12 @@ function explodePair( // (one per direction), each sized by its own count. const edges: AggregatedVisualEdge[] = []; for (const agg of typeAggs) { - const group = typeSets.getByIdx(agg.typeSetIdx); + const group = typeSets.getById(agg.typeSetId); const typeLabel = typeLabelForGroup(group, types); const inverseLabel = inverseLabelForGroup(group, types); const typeId = typeUrlForGroup(group, types); const color = edgeColorForType( - group ? primaryTypeOfSet(group.directTypeIdxs, types) : undefined, + group ? primaryTypeOfSet(group.directTypeIds, types) : undefined, types, ); @@ -472,12 +472,12 @@ function explodePair( edges.push({ kind: "aggregate", visualKey: VisualEdgeKey( - `agg:${pairKey}:${agg.typeSetIdx as number}:forward`, + `agg:${pairKey}:${agg.typeSetId as number}:forward`, ), source, target, pairKey, - typeSetIdx: agg.typeSetIdx, + typeSetId: agg.typeSetId, typeId, direction: "forward", collapsed: false, @@ -497,12 +497,12 @@ function explodePair( edges.push({ kind: "aggregate", visualKey: VisualEdgeKey( - `agg:${pairKey}:${agg.typeSetIdx as number}:reverse`, + `agg:${pairKey}:${agg.typeSetId as number}:reverse`, ), source, target, pairKey, - typeSetIdx: agg.typeSetIdx, + typeSetId: agg.typeSetId, typeId, direction: "reverse", collapsed: false, @@ -584,11 +584,11 @@ export class EdgeAggregator { * or undo it (sign = -1). */ #applyLink( - linkIdx: number, + linkId: number, leftIdx: number, rightIdx: number, - typeSetIdx: TypeSetIdx, - linkEntityIdx: EntityIdx, + typeSetId: TypeSetId, + linkEntityIdx: EntityIndex, cutIndex: CutIndex, sign: 1 | -1, ): void { @@ -609,15 +609,15 @@ export class EdgeAggregator { if (leftOwner === rightOwner) { if (cutIndex.isEntityMode(leftOwner)) { if (sign === 1) { - this.#individuals.set(linkIdx, { - linkIdx, + this.#individuals.set(linkId, { + linkId, ownerClusterId: leftOwner, leftEntityIdx: leftIdx, rightEntityIdx: rightIdx, - typeSetIdx, + typeSetId, }); } else { - this.#individuals.delete(linkIdx); + this.#individuals.delete(linkId); } } else { this.#hiddenCount += sign; @@ -640,16 +640,16 @@ export class EdgeAggregator { this.#pairs.set(key, pair); } - let typeAgg = pair.byType.get(typeSetIdx as number); + let typeAgg = pair.byType.get(typeSetId as number); if (!typeAgg) { typeAgg = { - typeSetIdx, + typeSetId, forwardCount: 0, reverseCount: 0, forwardLinks: new Set(), reverseLinks: new Set(), }; - pair.byType.set(typeSetIdx as number, typeAgg); + pair.byType.set(typeSetId as number, typeAgg); } if (forward) { typeAgg.forwardCount++; @@ -662,7 +662,7 @@ export class EdgeAggregator { } else { const pair = this.#pairs.get(key); if (pair) { - const typeAgg = pair.byType.get(typeSetIdx as number); + const typeAgg = pair.byType.get(typeSetId as number); if (typeAgg) { if (forward) { typeAgg.forwardCount--; @@ -672,7 +672,7 @@ export class EdgeAggregator { typeAgg.reverseLinks.delete(linkEntityIdx); } if (typeAgg.forwardCount <= 0 && typeAgg.reverseCount <= 0) { - pair.byType.delete(typeSetIdx as number); + pair.byType.delete(typeSetId as number); } } pair.totalCount--; @@ -693,8 +693,8 @@ export class EdgeAggregator { i, linkStore.getLeft(i) as number, linkStore.getRight(i) as number, - linkStore.getTypeSetIdx(i), - linkStore.getEntityIdx(i), + linkStore.getTypeSetId(i), + linkStore.getEntityIndex(i), cutIndex, 1, ); @@ -710,33 +710,33 @@ export class EdgeAggregator { const processedLinks = new Set(); for (const entityIdx of changedEntities) { - const links = linkStore.linksForEntity(entityIdx as EntityIdx); + const links = linkStore.linksFor(entityIdx as EntityIndex); for (const link of links) { - if (processedLinks.has(link.linkIdx)) { + if (processedLinks.has(link.linkId)) { continue; } - processedLinks.add(link.linkIdx); + processedLinks.add(link.linkId); - const leftIdx = linkStore.getLeft(link.linkIdx) as number; - const rightIdx = linkStore.getRight(link.linkIdx) as number; - const typeSetIdx = linkStore.getTypeSetIdx(link.linkIdx); - const linkEntityIdx = linkStore.getEntityIdx(link.linkIdx); + const leftIdx = linkStore.getLeft(link.linkId) as number; + const rightIdx = linkStore.getRight(link.linkId) as number; + const typeSetId = linkStore.getTypeSetId(link.linkId); + const linkEntityIdx = linkStore.getEntityIndex(link.linkId); // Undo old classification, apply new classification. this.#applyLink( - link.linkIdx, + link.linkId, leftIdx, rightIdx, - typeSetIdx, + typeSetId, linkEntityIdx, oldCutIndex, -1, ); this.#applyLink( - link.linkIdx, + link.linkId, leftIdx, rightIdx, - typeSetIdx, + typeSetId, linkEntityIdx, newCutIndex, 1, @@ -796,10 +796,10 @@ export class EdgeAggregator { // Individual edges. for (const edge of this.#individuals.values()) { - const group = typeSets.getByIdx(edge.typeSetIdx); + const group = typeSets.getById(edge.typeSetId); visualEdges.push({ kind: "individual", - visualKey: VisualEdgeKey(`link:${edge.linkIdx}`), + visualKey: VisualEdgeKey(`link:${edge.linkId}`), source: { kind: "entity", entityIdx: edge.leftEntityIdx, @@ -810,11 +810,11 @@ export class EdgeAggregator { entityIdx: edge.rightEntityIdx, ownerClusterId: edge.ownerClusterId, }, - linkIdx: edge.linkIdx, - typeSetIdx: edge.typeSetIdx, + linkId: edge.linkId, + typeSetId: edge.typeSetId, count: 1, color: edgeColorForType( - group ? primaryTypeOfSet(group.directTypeIdxs, types) : undefined, + group ? primaryTypeOfSet(group.directTypeIds, types) : undefined, types, ), widthWorld: 1, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts index 2f8140ed9d9..c7a0c3750ee 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts @@ -452,7 +452,7 @@ function mergeLanes( for (const child of children) { for (const edge of child.edges) { - const typeKey = (edge.typeSetIdx as number | undefined) ?? -1; + const typeKey = (edge.typeSetId as number | undefined) ?? -1; const direction = highwayDirection(edge, child.childId, side); const key = `${typeKey}:${direction}`; const existing = byLane.get(key); @@ -907,7 +907,7 @@ function emitRecursiveBezierFeeders( for (const child of children) { const childTypes = new Map(); for (const edge of child.edges) { - const typeKey = (edge.typeSetIdx as number | undefined) ?? -1; + const typeKey = (edge.typeSetId as number | undefined) ?? -1; const direction = highwayDirection(edge, child.childId, side); const key = `${typeKey}:${direction}`; const existing = childTypes.get(key); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts index 3b009cefa3c..ca7c27b95d5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts @@ -16,12 +16,12 @@ */ import { primaryTypeOfSet } from "../entity-style"; -import type { EntityIdx, TypeIdx } from "../../ids"; -import type { EntityStore } from "../stores/entity-store"; -import type { LinkStore } from "../stores/link-store"; -import type { PropertyStore } from "../stores/property-store"; -import type { TypeRegistry } from "../stores/type-registry"; -import type { TypeSetStore } from "../stores/type-set-store"; +import type { EntityIndex, TypeId } from "../../ids"; +import type { EntityStore } from "../store/entity"; +import type { LinkStore } from "../store/link"; +import type { PropertyStore } from "../store/property"; +import type { TypeRegistry } from "../store/type-registry"; +import type { TypeSetStore } from "../store/type-set"; import type { FeatureDescriptor, FeatureSource, @@ -46,28 +46,28 @@ export function createClusterFeatureSource( const { properties, links, entities, typeSets, types } = deps; /** The primary (most specific) type of the entity an endpoint points at, if known. */ - const targetTypeIdx = (otherIdx: EntityIdx): TypeIdx | undefined => { - const groupIdx = entities.getTypeGroup(otherIdx); + const targetTypeIdx = (otherIdx: EntityIndex): TypeId | undefined => { + const groupIdx = entities.getTypeSet(otherIdx); if (groupIdx === -1) { return undefined; } - const group = typeSets.getByIdx(groupIdx); + const group = typeSets.getById(groupIdx); if (!group) { return undefined; } - return primaryTypeOfSet(group.directTypeIdxs, types); + return primaryTypeOfSet(group.directTypeIds, types); }; return { - *keysOf(member: EntityIdx): Iterable { + *keysOf(member: EntityIndex): Iterable { const features = properties.featuresOf(member); if (features) { for (const featureIdx of features) { yield `p\u0000${featureIdx}`; } } - for (const link of links.linksForEntity(member)) { - const typeIdx = targetTypeIdx(link.otherIdx); + for (const link of links.linksFor(member)) { + const typeIdx = targetTypeIdx(link.otherId); if (typeIdx === undefined) { continue; } @@ -75,7 +75,7 @@ export function createClusterFeatureSource( } }, - *numericsOf(member: EntityIdx): Iterable { + *numericsOf(member: EntityIndex): Iterable { const keys = properties.numericKeysOf(member); const values = properties.numericValuesOf(member); if (!keys || !values) { @@ -101,7 +101,7 @@ export function createClusterFeatureSource( } if (parts[0] === "lt") { const direction = parts[1]; - const info = types.get(Number(parts[2]) as TypeIdx); + const info = types.get(Number(parts[2]) as TypeId); if (!info) { return undefined; } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts index 76c85e0ba0d..be7e79edfb2 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts @@ -8,10 +8,10 @@ import { subclusterByLinks } from "./community"; import type { VizConfig } from "../../config"; import type { Color } from "../../frames"; -import type { ClusterKind, EntityIdx, TypeIdx, TypeSetKey } from "../../ids"; -import type { LinkStore } from "../stores/link-store"; -import type { TypeRegistry } from "../stores/type-registry"; -import type { TypeSetGroup, TypeSetStore } from "../stores/type-set-store"; +import type { ClusterKind, EntityIndex, TypeId, TypeSetKey } from "../../ids"; +import type { LinkStore } from "../store/link"; +import type { TypeRegistry } from "../store/type-registry"; +import type { TypeSetGroup, TypeSetStore } from "../store/type-set"; /* eslint-disable no-bitwise */ function stableHashToAngle(id: string): number { @@ -25,13 +25,13 @@ function stableHashToAngle(id: string): number { export class ClusterLabel { readonly text: string; - readonly primaryType: TypeIdx | null; + readonly primaryType: TypeId | null; readonly coverage: number; readonly isMixed: boolean; constructor( text?: string, - primaryType?: TypeIdx | null, + primaryType?: TypeId | null, coverage?: number, isMixed?: boolean, ) { @@ -43,11 +43,11 @@ export class ClusterLabel { } export class ClusterMass { - readonly direct = new Map(); - readonly closure = new Map(); + readonly direct = new Map(); + readonly closure = new Map(); addGroup(group: TypeSetGroup): void { - for (const typeIdx of group.directTypeIdxs) { + for (const typeIdx of group.directTypeIds) { this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + group.count); } for (const typeIdx of group.closure.members()) { @@ -56,7 +56,7 @@ export class ClusterMass { } removeGroup(group: TypeSetGroup, entityCount: number): void { - for (const typeIdx of group.directTypeIdxs) { + for (const typeIdx of group.directTypeIds) { const prev = this.direct.get(typeIdx) ?? 0; this.direct.set(typeIdx, Math.max(0, prev - entityCount)); } @@ -67,7 +67,7 @@ export class ClusterMass { } incrementForGroup(group: TypeSetGroup, delta: number): void { - for (const typeIdx of group.directTypeIdxs) { + for (const typeIdx of group.directTypeIds) { this.direct.set(typeIdx, (this.direct.get(typeIdx) ?? 0) + delta); } for (const typeIdx of group.closure.members()) { @@ -92,7 +92,7 @@ interface GroupMembership { interface DirectMembership { readonly source: "direct"; - readonly members: Column; + readonly members: Column; } export type ClusterMembership = GroupMembership | DirectMembership; @@ -317,8 +317,8 @@ export function enclosingRadius(radii: readonly number[], gap: number): number { function documentFrequency( clusters: readonly ClusterNode[], massKey: "direct" | "closure", -): Map { - const df = new Map(); +): Map { + const df = new Map(); for (const cluster of clusters) { for (const typeIdx of cluster.mass[massKey].keys()) { @@ -331,13 +331,13 @@ function documentFrequency( function bestCandidate( cluster: ClusterNode, - mass: Map, - df: Map, + mass: Map, + df: Map, minCoverage: number, totalClusters: number, types: TypeRegistry, -): { typeIdx: TypeIdx; coverage: number; score: number } | undefined { - let best: { typeIdx: TypeIdx; coverage: number; score: number } | undefined; +): { typeIdx: TypeId; coverage: number; score: number } | undefined { + let best: { typeIdx: TypeId; coverage: number; score: number } | undefined; for (const [typeIdx, count] of mass) { const coverage = count / cluster.count; @@ -365,8 +365,8 @@ function bestCandidate( function distinctiveLabel( cluster: ClusterNode, - directDf: Map, - closureDf: Map, + directDf: Map, + closureDf: Map, totalClusters: number, types: TypeRegistry, ): ClusterLabel { @@ -407,7 +407,7 @@ function distinctiveLabel( function findMergeTarget( small: TypeSetGroup, - anchorIndex: Map, + anchorIndex: Map, config: VizConfig, ): ClusterId { const candidates = new Map(); @@ -428,9 +428,7 @@ function findMergeTarget( for (const anchor of candidates.values()) { const rawJaccard = small.closure.jaccard(anchor.closure); - const directSuperset = small.directTypeIdxs.isSubsetOf( - anchor.directTypeIdxs, - ); + const directSuperset = small.directTypeIds.isSubsetOf(anchor.directTypeIds); const adjustedScore = rawJaccard + (directSuperset ? 0.1 : 0); if ( @@ -450,14 +448,12 @@ function findMergeTarget( return best.group.standaloneClusterId; } - const primaryType = small.directTypeIdxs.items[0] ?? 0; + const primaryType = small.directTypeIds.items[0] ?? 0; return ClusterId(`cluster:other:${primaryType}`); } -function buildAnchorIndex( - typeSets: TypeSetStore, -): Map { - const index = new Map(); +function buildAnchorIndex(typeSets: TypeSetStore): Map { + const index = new Map(); for (const group of typeSets) { if (!group.isStandalone || group.count === 0) { @@ -491,7 +487,7 @@ function clusterDepth(cluster: ClusterNode): number { } function inheritedPrimaryType(cluster: ClusterNode): { - typeIdx: TypeIdx | null; + typeIdx: TypeId | null; inherited: boolean; } { if (cluster.label.primaryType !== null) { @@ -532,7 +528,7 @@ export function colorForCluster( } const info = types.get(primaryType); - const rootIdx = info?.rootIdxs[0] ?? primaryType; + const rootIdx = info?.rootIds[0] ?? primaryType; const slot = types.colorSlot(rootIdx); if (slot === undefined) { const alpha = depth > 0 ? 95 : 145; @@ -854,12 +850,12 @@ export class ClusterTree { node.clearChildren(); for (const { childId, count, memberIdxs } of childAssignments) { - const members = new Column( + const members = new Column( Int32Array, memberIdxs.length, ); for (const idx of memberIdxs) { - members.push(idx as EntityIdx); + members.push(idx as EntityIndex); } const child = new ClusterNode(childId, "embedding", { source: "direct", @@ -894,7 +890,7 @@ export class ClusterTree { #collectEntityIdxs( node: ClusterNode, typeSets: TypeSetStore, - ): Column { + ): Column { if (node.membership.source === "direct") { return node.membership.members; } @@ -903,15 +899,15 @@ export class ClusterTree { for (const key of node.membership.keys) { const group = typeSets.get(key); if (group) { - total += group.entityIdxs.length; + total += group.entities.length; } } - const result = new Column(Int32Array, total); + const result = new Column(Int32Array, total); for (const key of node.membership.keys) { const group = typeSets.get(key); if (group) { - for (const idx of group.entityIdxs) { + for (const idx of group.entities) { result.push(idx); } } @@ -979,7 +975,7 @@ export class ClusterTree { } } - const anchorIndex = new Map(); + const anchorIndex = new Map(); for (const anchor of anchors) { for (const typeIdx of anchor.closure.members()) { let list = anchorIndex.get(typeIdx); @@ -1103,13 +1099,13 @@ export class ClusterTree { #bucketByPrimaryRoot( atomicNodes: readonly ClusterNode[], types: TypeRegistry, - ): Map { - const buckets = new Map(); + ): Map { + const buckets = new Map(); for (const node of atomicNodes) { const primaryType = node.label.primaryType; const rootIdx = - primaryType !== null ? types.get(primaryType)?.rootIdxs[0] : undefined; + primaryType !== null ? types.get(primaryType)?.rootIds[0] : undefined; let list = buckets.get(rootIdx); if (!list) { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts index c8c77e2df62..1fa062619f7 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts @@ -15,8 +15,8 @@ import { import { ClusterLabel, ClusterNode } from "./cluster-tree"; import type { VizConfig } from "../../config"; -import type { EntityIdx } from "../../ids"; -import type { LinkStore } from "../stores/link-store"; +import type { EntityIndex } from "../../ids"; +import type { LinkStore } from "../store/link"; /* eslint-disable no-bitwise */ function deterministicShuffle(indices: number[], seed: number): number[] { @@ -126,9 +126,9 @@ function normalizeCommunitySizes( communities: number[][], graph: CsrGraph, config: VizConfig, -): EntityIdx[][] { - const result: EntityIdx[][] = []; - const tiny: EntityIdx[] = []; +): EntityIndex[][] { + const result: EntityIndex[][] = []; + const tiny: EntityIndex[] = []; for (const community of communities) { const entityIdxs = community.map((local) => graph.nodeIds.get(local)); @@ -158,14 +158,14 @@ function normalizeCommunitySizes( } function topDegreeEntity( - members: EntityIdx[], + members: EntityIndex[], links: LinkStore, -): EntityIdx | undefined { - let bestIdx: EntityIdx | undefined; +): EntityIndex | undefined { + let bestIdx: EntityIndex | undefined; let bestDegree = 0; for (const entityIdx of members) { - const degree = links.linksForEntity(entityIdx).length; + const degree = links.linksFor(entityIdx).length; if (degree > bestDegree) { bestDegree = degree; bestIdx = entityIdx; @@ -176,18 +176,18 @@ function topDegreeEntity( } function collectLinkFeatures( - members: EntityIdx[], + members: EntityIndex[], links: LinkStore, ): Map { const features = new Map(); const memberSet = new Set(members); for (const entityIdx of members) { - const endpoints = links.linksForEntity(entityIdx); + const endpoints = links.linksFor(entityIdx); for (const endpoint of endpoints) { - const isInternal = memberSet.has(endpoint.otherIdx); + const isInternal = memberSet.has(endpoint.otherId); const prefix = isInternal ? "int" : "ext"; - const key = `${prefix}:${endpoint.direction}:${endpoint.typeSetIdx}`; + const key = `${prefix}:${endpoint.direction}:${endpoint.typeSetId}`; features.set(key, (features.get(key) ?? 0) + 1); } } @@ -207,7 +207,7 @@ function featureKeyToLabel(key: string): string { * scored with TF-IDF across sibling communities. */ function labelAllCommunities( - communityMembers: EntityIdx[][], + communityMembers: EntityIndex[][], children: ClusterNode[], links: LinkStore, ): void { @@ -272,18 +272,18 @@ function labelAllCommunities( /* eslint-disable no-bitwise */ function linkSignatureKey( - entityIdx: EntityIdx, + entityIdx: EntityIndex, links: LinkStore, maxBuckets: number, ): string { - const endpoints = links.linksForEntity(entityIdx); + const endpoints = links.linksFor(entityIdx); if (endpoints.length === 0) { return "isolated"; } const features = new Set(); for (const endpoint of endpoints) { - features.add(`${endpoint.direction}:${endpoint.typeSetIdx}`); + features.add(`${endpoint.direction}:${endpoint.typeSetId}`); } const sorted = [...features].sort(); @@ -298,9 +298,9 @@ function linkSignatureKey( /* eslint-enable no-bitwise */ function columnFromIndices( - indices: ArrayLike, -): Column { - const col = new Column(Int32Array, indices.length); + indices: ArrayLike, +): Column { + const col = new Column(Int32Array, indices.length); for (let idx = 0; idx < indices.length; idx++) { col.push(indices[idx]!); } @@ -309,11 +309,11 @@ function columnFromIndices( function coarseLinkSignatureBuckets( cluster: ClusterNode, - entityIdxs: Column, + entityIdxs: Column, links: LinkStore, config: VizConfig, ): ClusterNode[] { - const buckets = new Map(); + const buckets = new Map(); for (const entityIdx of entityIdxs) { const key = linkSignatureKey(entityIdx, links, config.maxChildrenPerParent); @@ -353,7 +353,7 @@ function coarseLinkSignatureBuckets( */ function deterministicPartition( cluster: ClusterNode, - entityIdxs: Column, + entityIdxs: Column, config: VizConfig, ): ClusterNode[] { const targetSize = Math.floor( @@ -391,7 +391,7 @@ function deterministicPartition( */ export function subclusterByLinks( cluster: ClusterNode, - entityIdxs: Column, + entityIdxs: Column, links: LinkStore, config: VizConfig, ): ClusterNode[] { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts index e7118b2aaec..ccbf8a6bcd7 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.test.ts @@ -1,7 +1,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; -import { ClusterId, EntityIdx } from "../../ids"; +import { ClusterId, EntityIndex } from "../../ids"; import { nameClustersByDistinctiveFeatures } from "./distinctive-cluster-label"; import type { @@ -61,9 +61,9 @@ class FeatureModel { return dimension; } - addMember(spec: MemberSpec): EntityIdx { + addMember(spec: MemberSpec): EntityIndex { this.#members.push(spec); - return EntityIdx(this.#members.length - 1); + return EntityIndex(this.#members.length - 1); } source(): FeatureSource { @@ -79,7 +79,7 @@ class FeatureModel { } } -function cluster(id: string, members: readonly EntityIdx[]): ClusterMembers { +function cluster(id: string, members: readonly EntityIndex[]): ClusterMembers { return { childId: ClusterId(id), memberIdxs: Int32Array.from(members) }; } @@ -179,7 +179,7 @@ describe("nameClustersByDistinctiveFeatures", () => { // A and B uniquely share Region = US (rare across the 5 siblings, so it is each one's TOP // feature -> they collide); Tier is more widely shared, so it can only break the tie once // compounded on top of Region. - const members = (keys: readonly string[]): EntityIdx[] => + const members = (keys: readonly string[]): EntityIndex[] => [0, 1, 2].map(() => model.addMember({ keys, numerics: [] })); const groupA = members([us, gold]); const groupB = members([us, silver]); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts index 90d44b7f9ab..0d81690697e 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts @@ -35,7 +35,7 @@ * clusters. The chosen parts render in a deterministic order and join onto separate lines, so * a multi-part label reads as a stable little table inside the bubble and never reorders. */ -import type { ClusterId, EntityIdx } from "../../ids"; +import type { ClusterId, EntityIndex } from "../../ids"; export interface ClusterMembers { readonly childId: ClusterId; @@ -76,9 +76,9 @@ export interface NumericDimension { */ export interface FeatureSource { /** Stable feature keys for a member (exact property + link/target-type). */ - keysOf(member: EntityIdx): Iterable; + keysOf(member: EntityIndex): Iterable; /** Raw numeric/date readings for a member, for per-subdivision range bucketing. */ - numericsOf(member: EntityIdx): Iterable; + numericsOf(member: EntityIndex): Iterable; /** Describe a key returned by {@link keysOf}, or undefined to ignore it. */ describe(key: string): FeatureDescriptor | undefined; /** Describe a numeric dimension (title + kind), or undefined to ignore it. */ @@ -218,7 +218,7 @@ function buildNumericRanges( const valuesByAxis = new Map(); for (let cluster = 0; cluster < samples.length; cluster++) { for (const member of samples[cluster]!) { - for (const reading of source.numericsOf(member as EntityIdx)) { + for (const reading of source.numericsOf(member as EntityIndex)) { let perCluster = valuesByAxis.get(reading.dimension); if (!perCluster) { perCluster = samples.map(() => []); @@ -302,7 +302,7 @@ function clusterCoverage( for (const member of sample) { const seen = new Set(); - for (const key of source.keysOf(member as EntityIdx)) { + for (const key of source.keysOf(member as EntityIndex)) { if (seen.has(key)) { continue; } @@ -317,7 +317,7 @@ function clusterCoverage( seen.add(key); counts.set(key, (counts.get(key) ?? 0) + 1); } - for (const reading of source.numericsOf(member as EntityIdx)) { + for (const reading of source.numericsOf(member as EntityIndex)) { const range = ranges.get(reading.dimension); if (!range) { continue; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts new file mode 100644 index 00000000000..23cc71524e8 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts @@ -0,0 +1,393 @@ +/* eslint-disable no-console -- this is a throwaway-but-committed cost-attribution harness whose + whole purpose is to PRINT the per-pass breakdown table; see worker/layout/community-layout-cost.md */ +/** + * Per-pass cost attribution for the community-force layout (community-layout.ts). Two things live + * here, both driving deterministic (seeded) inputs so runs are reproducible: + * + * 1. A full-run attribution harness (module scope). It drives the REAL {@link CommunityLayout} + * from construction to `settled` with the opt-in {@link CommunityLayoutProfiler} seam and the + * production 1 ms tick budget, then prints an absolute-ms + %-of-run breakdown per pass, the + * seed-tick / FA2-iteration counts, and the avg ms/FA2-iteration split (iterate vs the + * worker-side stats + settle + scale overhead). This is the authoritative source for the + * numbers in community-layout-cost.md. + * + * 2. Isolated `bench()` cross-checks (vitest's statistical runner) for the heaviest passes that + * reproduce faithfully from public building blocks: Louvain graph build, Louvain solve, and + * the sparse-stress seed to completion. FA2 `iterate` and writePositions are NOT re-benched in + * isolation (they need the layout's private matrices); the harness measures them in situ. + * + * The profiler adds only a branch per timing site when absent, so production is byte-for-byte + * unchanged; it is supplied here purely to attribute wall-clock. + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts + */ +import { UndirectedGraph } from "graphology"; +import louvain from "graphology-communities-louvain"; +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildForceGraph } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { parkMillerRng } from "../random"; +import { createCommunityLayout } from "./community-layout"; +import { SparseStressSeeder } from "./sparse-stress-seed"; + +import type { GraphShape } from "../bench-fixtures"; +import type { + CommunityLayoutPass, + CommunityLayoutProfiler, +} from "./community-layout"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Representative deterministic sizes: ~300 / ~1.5k / ~5k nodes at a realistic (~2.6 edges/node) + * hub-skewed density. `profiledRuns` are averaged for the breakdown; the layout is deterministic + * so iteration COUNTS are identical run-to-run and only timing noise is averaged out. */ +interface CostCase { + readonly shape: GraphShape; + readonly profiledRuns: number; + readonly wallRuns: number; +} + +function shape(nodeCount: number, linkCount: number, seed: number): GraphShape { + return { + nodeCount, + linkCount, + typeCount: 1, + hubCount: Math.max(4, Math.round(nodeCount / 40)), + rootFraction: 1, + seed, + }; +} + +const COST_CASES: readonly CostCase[] = [ + { shape: shape(300, 780, 301), profiledRuns: 6, wallRuns: 3 }, + { shape: shape(1_500, 3_900, 302), profiledRuns: 4, wallRuns: 3 }, + { shape: shape(5_000, 13_000, 303), profiledRuns: 3, wallRuns: 2 }, +]; + +/** Tight bracket around inferSettings' `barnesHutOptimize: order > 2000` switch: two near-equal + * sizes, one just below (exact O(N^2) repulsion) and one just above (Barnes-Hut O(N log N)), to + * isolate the per-iterate cost cliff at the threshold from the effect of N itself. */ +const THRESHOLD_CASES: readonly CostCase[] = [ + { shape: shape(1_900, 4_940, 401), profiledRuns: 3, wallRuns: 1 }, + { shape: shape(2_100, 5_460, 402), profiledRuns: 3, wallRuns: 1 }, +]; + +/** Display order (grouped by phase); passes with no calls are omitted from the printout. */ +const PASS_ORDER: readonly CommunityLayoutPass[] = [ + "louvainBuild", + "louvainSolve", + "resolveEdges", + "matrixRebuild", + "seedSetup", + "seedSgd", + "fa2Iterate", + "fa2Stats", + "fa2Settle", + "fa2Scale", + "writePositions", +]; + +const FA2_OVERHEAD_PASSES: readonly CommunityLayoutPass[] = [ + "fa2Stats", + "fa2Settle", + "fa2Scale", +]; + +/** Seed options mirroring CommunityLayout.#buildSeed exactly, so the isolated seed bench measures + * the same work the layout does. */ +const SEED_OPTIONS = { + idealEdgeLength: 40, + randomSeed: 1, + jitter: 0.01, + packComponents: true, + returnPivotDistances: false, +} as const; + +/** Fresh node objects per run: the solver mutates node x/y in place, so a shared array would let + * a run warm-start from the previous run's settled positions. */ +function cloneNodes(nodes: readonly ForceNode[]): ForceNode[] { + return nodes.map((node) => ({ ...node })); +} + +/** Drive the phase machine to convergence with the worker scheduler's 1 ms budget, so + * writePositions is attributed once per frame (its real cadence), not once per giant tick. */ +function driveToSettled(layout: LayoutSimulation): void { + let guard = 0; + while (!layout.isSettled && guard < 10_000_000) { + if (!layout.tick(1)) { + break; + } + guard += 1; + } +} + +interface PassStat { + readonly totalMs: number; + readonly calls: number; +} + +/** Mean per-pass ms + per-run call count over `runs` profiled cold runs, plus the mean profiled + * wall-clock (which includes the profiler's own bookkeeping overhead). */ +function attributePasses( + caseShape: GraphShape, + runs: number, +): { + readonly perPass: Map; + readonly wallMs: number; +} { + const { nodes, edges } = buildForceGraph(caseShape); + const totals = new Map< + CommunityLayoutPass, + { totalMs: number; calls: number } + >(); + let wallMs = 0; + + for (let run = 0; run < runs; run++) { + const profiler: CommunityLayoutProfiler = { + add(pass, elapsedMs) { + const accumulated = totals.get(pass); + if (accumulated) { + accumulated.totalMs += elapsedMs; + accumulated.calls += 1; + } else { + totals.set(pass, { totalMs: elapsedMs, calls: 1 }); + } + }, + }; + const buffer = new FlatGraphBuffer(Math.max(1, nodes.length)); + const wallStart = performance.now(); + const layout = createCommunityLayout( + cloneNodes(nodes), + edges, + buffer, + undefined, + profiler, + ); + driveToSettled(layout); + wallMs += performance.now() - wallStart; + } + + const perPass = new Map(); + for (const [pass, accumulated] of totals) { + perPass.set(pass, { + totalMs: accumulated.totalMs / runs, + calls: accumulated.calls / runs, + }); + } + return { perPass, wallMs: wallMs / runs }; +} + +/** Least-noisy (minimum) unprofiled construct-to-settled wall-clock: the real production total. */ +function productionWallMs(caseShape: GraphShape, runs: number): number { + const { nodes, edges } = buildForceGraph(caseShape); + let best = Number.POSITIVE_INFINITY; + for (let run = 0; run < runs; run++) { + const buffer = new FlatGraphBuffer(Math.max(1, nodes.length)); + const start = performance.now(); + const layout = createCommunityLayout(cloneNodes(nodes), edges, buffer); + driveToSettled(layout); + const elapsed = performance.now() - start; + if (elapsed < best) { + best = elapsed; + } + } + return best; +} + +function pad(text: string, width: number, alignRight = true): string { + return alignRight ? text.padStart(width) : text.padEnd(width); +} + +/** Build the full per-case report as one string (printed in a single console.log so vitest keeps + * each case's table contiguous rather than interleaving lines by call site). */ +function attributionReport(costCase: CostCase): string { + const { shape: caseShape, profiledRuns, wallRuns } = costCase; + const { nodes, edges } = buildForceGraph(caseShape); + const { perPass, wallMs } = attributePasses(caseShape, profiledRuns); + const wall = productionWallMs(caseShape, wallRuns); + + let sumMs = 0; + for (const stat of perPass.values()) { + sumMs += stat.totalMs; + } + + const columns = [22, 9, 12, 11, 8]; + const lines: string[] = []; + const row = (cells: readonly string[]): string => + [ + pad(cells[0]!, columns[0]!, false), + pad(cells[1]!, columns[1]!), + pad(cells[2]!, columns[2]!), + pad(cells[3]!, columns[3]!), + pad(cells[4]!, columns[4]!), + ].join(" "); + + const header = row(["pass", "calls", "total ms", "ms/call", "% run"]); + lines.push( + `\n=== community-force cost attribution: ${nodes.length} nodes / ${edges.length} edges ` + + `(barnesHut ${caseShape.nodeCount > 2000 ? "ON" : "OFF"}, mean of ${profiledRuns} profiled runs) ===`, + ); + lines.push(header); + lines.push("-".repeat(header.length)); + + for (const pass of PASS_ORDER) { + const stat = perPass.get(pass); + if (!stat || stat.calls === 0) { + continue; + } + const perCall = stat.totalMs / stat.calls; + const percent = sumMs > 0 ? (stat.totalMs / sumMs) * 100 : 0; + lines.push( + row([ + pass, + stat.calls.toFixed(stat.calls < 100 ? 1 : 0), + stat.totalMs.toFixed(2), + perCall.toFixed(4), + `${percent.toFixed(1)}%`, + ]), + ); + } + + lines.push("-".repeat(header.length)); + lines.push(row(["sum of passes", "", sumMs.toFixed(2), "", "100.0%"])); + + const seedTicks = + (perPass.get("seedSetup")?.calls ?? 0) + + (perPass.get("seedSgd")?.calls ?? 0); + const fa2Iters = perPass.get("fa2Iterate")?.calls ?? 0; + const fa2IterateMs = perPass.get("fa2Iterate")?.totalMs ?? 0; + let fa2OverheadMs = 0; + for (const pass of FA2_OVERHEAD_PASSES) { + fa2OverheadMs += perPass.get(pass)?.totalMs ?? 0; + } + const perIterate = fa2Iters > 0 ? fa2IterateMs / fa2Iters : 0; + const perOverhead = fa2Iters > 0 ? fa2OverheadMs / fa2Iters : 0; + + lines.push( + `production wall (unprofiled, min of ${wallRuns}): ${wall.toFixed(2)} ms | ` + + `profiled wall: ${wallMs.toFixed(2)} ms | unattributed (loop/handoff/profiler): ` + + `${(wallMs - sumMs).toFixed(2)} ms`, + ); + lines.push( + `seed ticks: ${seedTicks.toFixed(0)} | FA2 iterations: ${fa2Iters.toFixed(0)} | ` + + `writePositions calls: ${(perPass.get("writePositions")?.calls ?? 0).toFixed(0)}`, + ); + lines.push( + `avg ms / FA2 iteration: iterate ${perIterate.toFixed(4)} + overhead ` + + `${perOverhead.toFixed(4)} = ${(perIterate + perOverhead).toFixed(4)}`, + ); + + return lines.join("\n"); +} + +for (const costCase of [...COST_CASES, ...THRESHOLD_CASES]) { + console.log(attributionReport(costCase)); +} + +/** Louvain graph build, mirroring CommunityLayout.#runLouvain (addNode per node, mergeEdge per + * resolved edge). Fixture edges are already unique index pairs, so mergeEdge count matches. */ +function buildLouvainGraph( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], +): UndirectedGraph, { weight: number }> { + const graph = new UndirectedGraph< + Record, + { weight: number } + >(); + for (const node of nodes) { + graph.addNode(node.id); + } + for (const edge of edges) { + const source = + typeof edge.source === "string" ? edge.source : edge.source.id; + const target = + typeof edge.target === "string" ? edge.target : edge.target.id; + graph.mergeEdge(source, target, { weight: edge.weight }); + } + return graph; +} + +/** Index-based src/dst arrays for the seeder, matching CommunityLayout.#buildSeed's inputs. */ +function seedEdgeArrays( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], +): { readonly src: Uint32Array; readonly dst: Uint32Array } { + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + const src = new Uint32Array(edges.length); + const dst = new Uint32Array(edges.length); + let count = 0; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + src[count] = source; + dst[count] = target; + count += 1; + } + return { src: src.slice(0, count), dst: dst.slice(0, count) }; +} + +/** Fixed-count runs: settling/seeding are too slow for vitest's default time budget. */ +const BENCH_OPTIONS = { + time: 0, + iterations: 8, + warmupTime: 0, + warmupIterations: 2, +} as const; + +for (const { shape: caseShape } of COST_CASES) { + describe(`community-layout passes (${caseShape.nodeCount} nodes)`, () => { + const { nodes, edges } = buildForceGraph(caseShape); + const { src, dst } = seedEdgeArrays(nodes, edges); + const prebuiltGraph = buildLouvainGraph(nodes, edges); + + bench( + "louvain: build graph", + () => { + buildLouvainGraph(nodes, edges); + }, + BENCH_OPTIONS, + ); + + bench( + "louvain: solve", + () => { + louvain(prebuiltGraph, { + getEdgeWeight: "weight", + randomWalk: false, + rng: parkMillerRng(1), + }); + }, + BENCH_OPTIONS, + ); + + bench( + "seed: run to completion", + () => { + const seedX = new Float32Array(nodes.length); + const seedY = new Float32Array(nodes.length); + new SparseStressSeeder( + { n: nodes.length, src, dst, x: seedX, y: seedY }, + SEED_OPTIONS, + ).run(); + }, + BENCH_OPTIONS, + ); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.md new file mode 100644 index 00000000000..7d2b568e14b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.md @@ -0,0 +1,198 @@ +# Community-force layout: per-pass cost attribution + +Where wall-clock time goes across a **cold** community-force layout run (construction through +`status === "settled"`), attributed per pass with real measurements. Scope is +`worker/layout/community-layout.ts` (the medium-scale FA2 engine) and its seeder +`worker/layout/sparse-stress-seed.ts`. + +> A separate broader investigation owns `worker/PERFORMANCE.md`; this file is the layout-specific +> deep-dive and does not touch it. + +## How this was measured + +Two committed artifacts, both driving deterministic (seeded) inputs from +`worker/bench-fixtures.ts` (`buildForceGraph`), so every run is reproducible: + +1. **Full-run attribution harness** — `community-layout-cost.bench.ts`. Drives the **real** + `CommunityLayout` from construction to `settled` with the production **1 ms tick budget** + (`graph-worker.ts` calls `layout.tick(1)`), and an opt-in `CommunityLayoutProfiler` that + accumulates wall-clock + call counts per pass. Prints the tables below. +2. **Isolated `bench()` cross-checks** — same file: Louvain graph build, Louvain solve, and the + sparse-stress seed to completion, run through vitest's statistical runner to sanity-check the + harness numbers. + +**Instrumentation seam.** `community-layout.ts` gained an optional last constructor arg +`profiler?: CommunityLayoutProfiler` (threaded through `createCommunityLayout`). Every timing site +is guarded on `#profiler` being set (`#now()` returns `0` and `#record()` is a no-op when absent), +so **production is byte-for-byte unchanged** — no clock reads, no allocation, only a branch. All 28 +`worker/layout` tests pass with the seam in place. Measured profiler overhead is ~1–4% of wall +(e.g. 7802 ms profiled vs 7490 ms unprofiled at 5k), so the per-pass split (denominator = +sum-of-passes) is trustworthy; the "unattributed" residual (tick-loop `performance.now`, seed +hand-off copy, profiler bookkeeping) is <4 ms at every size, i.e. the seam captures essentially the +whole run. + +Reproduce (from `apps/hash-frontend`; the app has no vitest config, so defaults pick up +`*.bench.ts` — note `yarn vitest` is not a script here, invoke the binary directly): + +```bash +node_modules/.bin/vitest bench --run \ + src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts +``` + +**Caveats.** Absolute ms are one machine (Apple Silicon, Node/vitest 4.1.8) — the cross-size +**shape and ratios** are the point, not the absolute numbers. The synthetic hub-skewed fixture is +not identical to real entity graphs (topology drives FA2 iteration count). Seed setup-vs-SGD is +bucketed by the seeder phase at each tick's start, so 1–2 boundary ticks bleed between buckets +(immaterial to the totals). + +## Per-pass attribution (mean over runs; % of the cold run) + +Passes: `louvainBuild`/`louvainSolve` = build the graphology graph vs run `louvain()`; +`resolveEdges` = the `"lo:hi"` string-keyed parallel-edge merge; `matrixRebuild` = FA2 Float32Array +fills; `seedSetup` = seeder CSR + weak-components + pivot BFS + coord init + pack; `seedSgd` = the +stress-SGD relaxation ticks; `fa2Iterate` = the library `iterate()` call; `fa2Stats` = `#fa2IterStats` +(O(N) move scan); `fa2Settle` = `#afterFa2Iteration` EMA/streak bookkeeping (O(1)); `fa2Scale` = +`#estimateTypicalEdgeLength` (O(E), every 8 iters); `writePositions` = per-tick re-centre + buffer +commit. + +### ~300 nodes / 717 edges — Barnes-Hut OFF (exact O(N²) repulsion) + +| pass | calls | total ms | ms/call | % | +| -------------- | ----: | ---------: | ------: | ----: | +| fa2Iterate | 917 | 201.91 | 0.2202 | 97.7% | +| seedSgd | 7 | 1.46 | 0.2083 | 0.7% | +| seedSetup | 4 | 1.06 | 0.2655 | 0.5% | +| louvainSolve | 1 | 0.60 | 0.6037 | 0.3% | +| louvainBuild | 1 | 0.41 | 0.4143 | 0.2% | +| fa2Stats | 917 | 0.42 | 0.0005 | 0.2% | +| fa2Scale | 115 | 0.23 | 0.0020 | 0.1% | +| writePositions | 188 | 0.24 | 0.0013 | 0.1% | +| resolveEdges | 1 | 0.12 | 0.1202 | 0.1% | +| matrixRebuild | 1 | 0.06 | 0.0550 | 0.0% | +| fa2Settle | 917 | 0.08 | 0.0001 | 0.0% | +| **sum** | | **206.60** | | 100% | + +Production wall (unprofiled, min of 3): **203 ms**. + +### ~1500 nodes / 3833 edges — Barnes-Hut OFF (exact O(N²) repulsion) + +| pass | calls | total ms | ms/call | % | +| -------------- | ----: | ----------: | ------: | ----: | +| fa2Iterate | 504 | 2777.52 | 5.5110 | 99.5% | +| seedSgd | 17 | 2.93 | 0.1726 | 0.1% | +| writePositions | 508 | 2.92 | 0.0057 | 0.1% | +| louvainSolve | 1 | 2.14 | 2.1380 | 0.1% | +| louvainBuild | 1 | 2.11 | 2.1095 | 0.1% | +| seedSetup | 17 | 0.96 | 0.0567 | 0.0% | +| fa2Stats | 504 | 0.89 | 0.0018 | 0.0% | +| fa2Scale | 64 | 0.83 | 0.0130 | 0.0% | +| resolveEdges | 1 | 0.46 | 0.4589 | 0.0% | +| fa2Settle | 504 | 0.07 | 0.0001 | 0.0% | +| matrixRebuild | 1 | 0.03 | 0.0275 | 0.0% | +| **sum** | | **2790.86** | | 100% | + +Production wall (unprofiled, min of 3): **2816 ms**. + +### ~5000 nodes / 12927 edges — Barnes-Hut ON (O(N log N) repulsion) + +| pass | calls | total ms | ms/call | % | +| -------------- | ----: | ----------: | ------: | ----: | +| fa2Iterate | 498 | 7768.68 | 15.5998 | 99.6% | +| writePositions | 501 | 8.91 | 0.0178 | 0.1% | +| louvainSolve | 1 | 7.85 | 7.8479 | 0.1% | +| louvainBuild | 1 | 6.37 | 6.3725 | 0.1% | +| fa2Stats | 498 | 2.66 | 0.0053 | 0.0% | +| seedSetup | 50 | 2.22 | 0.0445 | 0.0% | +| fa2Scale | 63 | 2.00 | 0.0318 | 0.0% | +| resolveEdges | 1 | 1.42 | 1.4230 | 0.0% | +| seedSgd | 9 | 0.84 | 0.0928 | 0.0% | +| matrixRebuild | 1 | 0.06 | 0.0606 | 0.0% | +| fa2Settle | 498 | 0.04 | 0.0001 | 0.0% | +| **sum** | | **7801.06** | | 100% | + +Production wall (unprofiled, min of 2): **7490 ms**. + +## Iteration counts and avg ms per FA2 iteration + +| size (nodes) | Barnes-Hut | seed ticks | FA2 iters | ms/iter `iterate` | ms/iter overhead | overhead share | +| -----------: | :--------: | ---------: | --------: | ----------------: | ---------------: | -------------: | +| 300 | OFF | 11 | 917 | 0.2202 | 0.0008 | 0.36% | +| 1500 | OFF | 34 | 504 | 5.5110 | 0.0036 | 0.07% | +| 5000 | ON | 59 | 498 | 15.5998 | 0.0094 | 0.06% | + +"overhead" = `fa2Stats + fa2Settle + fa2Scale` per iteration (the worker-side per-iteration work +around the library call). + +## Barnes-Hut threshold bracket (the headline) + +`inferSettings` (graphology) sets `barnesHutOptimize: order > 2000`. Two near-equal sizes straddling +that switch isolate the per-iterate cliff from the effect of N itself: + +| size (nodes) | Barnes-Hut | FA2 iters | ms/iter `iterate` | cold run (sum) | +| -----------: | :--------: | --------: | ----------------: | -------------: | +| 1900 | OFF | 513 | 8.9061 | 4586 ms | +| 2100 | ON | 552 | 5.2981 | 2942 ms | + +**+200 nodes (+11%) across the threshold makes each FA2 iteration 1.68× cheaper and the whole cold +run 1.6× faster** — purely because exact O(N²) repulsion flips to Barnes-Hut. Extrapolating the +exact-regime rate (0.2202 ms/iter at 300 → `× (N/300)²`) predicts ~61 ms/iter for exact repulsion at +5000; Barnes-Hut delivers 15.6 ms/iter, ~3.9× faster. + +## Which pass dominates, and how the split shifts with size + +- **FA2 `iterate` dominates at every size: 97.7% → 99.5% → 99.6%.** There is no crossover — FA2 is + always the cost, and cold-run wall ≈ `FA2 iters × per-iterate cost`. Everything else combined is + <2.5% (300 nodes) and <0.5% (≥1.5k). +- What shifts with size is the **per-iterate cost**, not which pass wins: + - **≤2000 nodes (exact repulsion):** per-iterate grows **quadratically** — 0.22 ms (300) → 5.51 ms + (1500) → 8.91 ms (1900). The 300→1500 jump is 25× for 5× the nodes (clean O(N²)). + - **>2000 nodes (Barnes-Hut):** per-iterate grows ~N log N — 5.30 ms (2100) → 15.60 ms (5000), + clearly sub-quadratic. +- **FA2 iteration count does NOT grow with N** (917 → ~500). Small graphs run _more_ iterations + (adjustSizes overlap churn stays above the relative-move threshold longer at small scale) but each + is cheap. So total cold time is driven by per-iterate cost, not iteration growth. +- Louvain / seed / matrix / writePositions absolute costs do grow with N/E, but stay <1% of the run + throughout. + +## Surprising / wasteful findings + +1. **Worker-side FA2 overhead is a non-issue — the "overhead scans rival `iterate`" hypothesis is + false.** `fa2Stats` (O(N) move scan) + `fa2Settle` + `fa2Scale` (O(E) every 8 iters) total + **<0.01 ms/iter** at every size — 3–4 orders of magnitude below `iterate` (0.22–15.6 ms/iter), + i.e. <0.1% of the run. The settle/stats/scale scans are not worth optimizing. +2. **The Barnes-Hut cliff at order > 2000 is the single biggest lever.** A 1500-node graph (2.8 s + cold) is nearly as slow as a 5000-node graph (7.5 s) despite 3.3× fewer nodes, because it pays + exact O(N²) repulsion. The whole 200–2000-node band — the common medium tier — runs on the + expensive path. +3. **`iterate` is ~99% of the run, so only two things matter for cold latency: per-iterate cost and + iteration count.** Optimizing Louvain, the seed, matrix build, `resolveEdges`, or writePositions + cannot move cold-run wall meaningfully (all <1%). +4. **Graph construction rivals the Louvain solve.** Building the graphology `UndirectedGraph` + (`addNode`/`mergeEdge`) costs ≈ the `louvain()` call itself (300: 0.41 vs 0.60 ms; 1500: 2.11 vs + 2.14 ms; isolated bench even shows build > solve at 1500). Both are trivial for a cold run, but + see the absorb note. +5. **Small graphs over-iterate.** 300 nodes runs 917 FA2 iterations vs ~500 for 1.5k–5k. `FA2_MIN_ITERS` + (120) plus adjustSizes jitter relative to the small typical-edge-length scale keeps the relative + move above threshold longer. + +### Absorb / streaming-path note (not a cold-run cost) + +`absorb()` calls `#rebuildMatrices` every time (which re-runs `resolveEdges`, rebuilding the whole +`Map<"lo:hi", IndexEdge>` string-keyed edge map and both Float32Array matrices from scratch), and +past the growth threshold also `#runLouvain` (which rebuilds the entire graphology graph — re-`addNode` +all nodes, re-`mergeEdge` all edges). All O(N+E) allocation per streaming batch. Cheap next to one FA2 +settle, but it is pure re-allocation churn repeated per batch, and the string-keyed `${lo}:${hi}` map +is the allocation hotspot there (0.12–1.42 ms per rebuild, ~20× the raw matrix fill, growing with E). + +## Optimization ideas (notes only — no behavior changed here) + +- **Enable Barnes-Hut below 2000 nodes** (override `barnesHutOptimize` in `buildFa2Settings`, and/or + expose `barnesHutTheta`). Highest leverage: attacks ~99% of the cost for the most common tier. The + quality trade-off (Barnes-Hut is approximate) should be checked against the existing layout tests. +- **Cut FA2 iterations.** A tighter seed (fewer FA2 iters to settle) or a size-aware + `FA2_MIN_ITERS`/settle streak for small graphs that over-iterate (917 iters at 300 nodes) is linear + savings, since `iterate` is ~99%. +- **Don't bother** micro-optimizing `fa2Stats`/`fa2Settle`/`fa2Scale`/`writePositions`/Louvain/seed + for cold latency — the ceiling is <1% combined. +- For the **absorb** path specifically, an incremental edge/matrix update (append instead of full + rebuild) and a non-string edge key would remove per-batch allocation churn. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts index 633284f9532..e65bf9624d9 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts @@ -168,6 +168,45 @@ interface IndexEdge { type CommunityPhase = "seed" | "fa2" | "done"; +/** + * A single attributable pass of a cold layout run, for the {@link CommunityLayoutProfiler} + * cost deep-dive (worker/layout/community-layout-cost.md). `louvain*` and `matrixRebuild`/ + * `resolveEdges` run at build; `seed*` during the seed phase; `fa2*` per FA2 iteration; + * `writePositions` after every stepped tick. + */ +export type CommunityLayoutPass = + | "louvainBuild" + | "louvainSolve" + | "resolveEdges" + | "matrixRebuild" + | "seedSetup" + | "seedSgd" + | "fa2Iterate" + | "fa2Stats" + | "fa2Settle" + | "fa2Scale" + | "writePositions"; + +/** + * Opt-in instrumentation sink for the per-pass cost deep-dive. Production never supplies one: + * with no profiler every timing site is skipped (guarded on `#profiler`), so the layout runs + * exactly as before. The cost harness (community-layout-cost.bench.ts) supplies one to attribute + * wall-clock and call counts per {@link CommunityLayoutPass} across a full cold run. `add` is + * called once per pass occurrence, so the number of calls is also the pass's iteration count. + */ +export interface CommunityLayoutProfiler { + readonly add: (pass: CommunityLayoutPass, elapsedMs: number) => void; +} + +/** Seeder phases that are the stress-SGD relaxation itself; everything else the seeder does + * (CSR build, weak components, pivot BFS, coordinate init, component packing) is setup. Used + * only to bucket seed-tick wall-clock into {@link CommunityLayoutPass} `seedSgd` vs `seedSetup`. */ +const SEED_SGD_PHASES: ReadonlySet = new Set([ + "stress-edges", + "stress-pivots", + "stress-flow", +]); + class CommunityLayout implements LayoutSimulation { readonly #nodes: ForceNode[]; /** Stable reference: the buffer grows in place (`ensureCapacity` on the instance the @@ -209,15 +248,19 @@ class CommunityLayout implements LayoutSimulation { #absorbedSinceLouvain = 0; /** Node count at the last Louvain refresh, base for the growth-fraction trigger. */ #countAtLastLouvain = 0; + /** Opt-in per-pass cost profiler; undefined in production (all timing sites then skip). */ + readonly #profiler: CommunityLayoutProfiler | undefined; constructor( nodes: ForceNode[], edges: ForceEdge[], buffer: FlatGraphBuffer, fa2Tuning?: Fa2Tuning, + profiler?: CommunityLayoutProfiler, ) { this.#nodes = nodes; this.#buffer = buffer; + this.#profiler = profiler; const count = nodes.length; for (const [index, node] of nodes.entries()) { @@ -237,7 +280,23 @@ class CommunityLayout implements LayoutSimulation { this.#status = count > 0 ? "running" : "settled"; this.#phase = count > 0 ? "seed" : "done"; + const writeStart = this.#now(); this.#writePositions(); + this.#record("writePositions", writeStart); + } + + /** `performance.now()` when profiling, else 0 (no clock read). Paired with {@link #record} + * so a disabled profiler adds only a branch per timing site, no allocation. */ + #now(): number { + return this.#profiler === undefined ? 0 : performance.now(); + } + + /** Attribute `now - start` ms to `pass` when profiling; a no-op otherwise. */ + #record(pass: CommunityLayoutPass, start: number): void { + const profiler = this.#profiler; + if (profiler !== undefined) { + profiler.add(pass, performance.now() - start); + } } /** @@ -250,8 +309,11 @@ class CommunityLayout implements LayoutSimulation { */ #rebuildMatrices(edges: ForceEdge[]): void { const count = this.#nodes.length; + const resolveStart = this.#now(); this.#indexEdges = CommunityLayout.resolveEdges(edges, this.#idToIndex); + this.#record("resolveEdges", resolveStart); + const matrixStart = this.#now(); const nodeMatrix = new Float32Array(count * PPN); for (let idx = 0; idx < count; idx++) { const node = this.#nodes[idx]!; @@ -278,6 +340,7 @@ class CommunityLayout implements LayoutSimulation { this.#nodeMatrix = nodeMatrix; this.#edgeMatrix = edgeMatrix; this.#prevPositions = new Float32Array(count * 2); + this.#record("matrixRebuild", matrixStart); } get status(): ForceLayoutStatus { @@ -323,7 +386,9 @@ class CommunityLayout implements LayoutSimulation { } if (stepped) { + const writeStart = this.#now(); this.#writePositions(); + this.#record("writePositions", writeStart); } if (this.#phase === "done") { this.#status = "settled"; @@ -412,16 +477,30 @@ class CommunityLayout implements LayoutSimulation { #advance(): void { switch (this.#phase) { case "seed": { - if (this.#seed!.tick({ maxWork: SEED_TICK_WORK }).done) { + const seed = this.#seed!; + // Classify the whole tick by the phase it starts in (setup vs SGD relaxation). A tick + // spans thousands of work units within one phase, so only the 1-2 boundary ticks bleed. + const seedPass = SEED_SGD_PHASES.has(seed.phase) + ? "seedSgd" + : "seedSetup"; + const seedStart = this.#now(); + const done = seed.tick({ maxWork: SEED_TICK_WORK }).done; + this.#record(seedPass, seedStart); + if (done) { this.#handOffSeedToFa2(); this.#phase = "fa2"; } break; } case "fa2": { + const iterStart = this.#now(); iterate(this.#fa2Settings, this.#nodeMatrix, this.#edgeMatrix); + this.#record("fa2Iterate", iterStart); this.#fa2Steps += 1; - const converged = this.#afterFa2Iteration(this.#fa2IterStats()); + const statsStart = this.#now(); + const stats = this.#fa2IterStats(); + this.#record("fa2Stats", statsStart); + const converged = this.#afterFa2Iteration(stats); if (converged || this.#fa2Steps >= FA2_MAX_ITERS) { this.#phase = "done"; } @@ -441,6 +520,7 @@ class CommunityLayout implements LayoutSimulation { return; } + const buildStart = this.#now(); const graph = new UndirectedGraph< Record, { weight: number } @@ -457,12 +537,15 @@ class CommunityLayout implements LayoutSimulation { }, ); } + this.#record("louvainBuild", buildStart); + const solveStart = this.#now(); const membership = louvain(graph, { getEdgeWeight: "weight", randomWalk: false, rng: parkMillerRng(1), }); + this.#record("louvainSolve", solveStart); for (let idx = 0; idx < this.#nodes.length; idx++) { this.#communities[idx] = membership[this.#nodes[idx]!.id] ?? idx; } @@ -550,8 +633,11 @@ class CommunityLayout implements LayoutSimulation { */ #afterFa2Iteration(stats: Fa2IterStats): boolean { if (this.#fa2Steps === 1 || this.#fa2Steps % FA2_SCALE_REFRESH === 0) { + const scaleStart = this.#now(); this.#fa2Scale = this.#estimateTypicalEdgeLength(); + this.#record("fa2Scale", scaleStart); } + const settleStart = this.#now(); const scale = Math.max(1e-6, this.#fa2Scale); const rmsRel = stats.rmsMove / scale; const maxRel = stats.maxMove / scale; @@ -573,10 +659,11 @@ class CommunityLayout implements LayoutSimulation { this.#fa2MaxMoveEma < FA2_SETTLE_MAX_REL; this.#fa2SettledFor = settledNow ? this.#fa2SettledFor + 1 : 0; - return ( + const converged = this.#fa2Steps >= FA2_MIN_ITERS && - this.#fa2SettledFor >= FA2_SETTLE_STREAK - ); + this.#fa2SettledFor >= FA2_SETTLE_STREAK; + this.#record("fa2Settle", settleStart); + return converged; } /** Reset the FA2 settle smoothing + streak (a warm absorb re-energises the layout). */ @@ -695,6 +782,7 @@ export function createCommunityLayout( edges: ForceEdge[], buffer: FlatGraphBuffer, fa2Tuning?: Fa2Tuning, + profiler?: CommunityLayoutProfiler, ): LayoutSimulation { - return new CommunityLayout(nodes, edges, buffer, fa2Tuning); + return new CommunityLayout(nodes, edges, buffer, fa2Tuning, profiler); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts index 62a2322965b..d13a12cd710 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/protocol.ts @@ -2,7 +2,7 @@ import type { VizConfig } from "../config"; import type { PositionsFrame, StructureFrame } from "../frames"; -import type { ClusterId, EntityIdx, VizMode } from "../ids"; +import type { ClusterId, EntityIndex, VizMode } from "../ids"; import type { EntityId, LinkData, @@ -84,7 +84,7 @@ export interface EmbeddingClusteringResultMessage { * itself when individually rendered, or the cluster bubble it is collapsed into. */ export type EgoTarget = - | { readonly kind: "entity"; readonly entityIdx: EntityIdx } + | { readonly kind: "entity"; readonly entityIdx: EntityIndex } | { readonly kind: "cluster"; readonly clusterId: ClusterId }; /** @@ -96,7 +96,7 @@ export interface QueryEgoMessage { readonly type: "QUERY_EGO"; readonly requestId: number; /** EntityIdx (join key) of the selected node. */ - readonly entityIdx: EntityIdx; + readonly entityIdx: EntityIndex; } /** @@ -115,7 +115,7 @@ export interface SetPinnedMessage { */ export interface SetHighlightMessage { readonly type: "SET_HIGHLIGHT"; - readonly entityIdxs: readonly EntityIdx[]; + readonly entityIdxs: readonly EntityIndex[]; } /** @@ -264,7 +264,7 @@ export interface EgoResultMessage { export interface HighwayLinksResultMessage { readonly type: "HIGHWAY_LINKS_RESULT"; readonly requestId: number; - readonly linkEntityIdxs: readonly EntityIdx[]; + readonly linkEntityIdxs: readonly EntityIndex[]; } export type WorkerToMainMessage = diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.test.ts similarity index 63% rename from apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.test.ts index 2363292431c..ed9b6146dfe 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { entityIdFromComponents } from "@blockprotocol/type-system"; import { decodeEntityId, ID_HEADER_BYTES } from "../entity-id-codec"; -import { EntityStore } from "./entity-store"; +import { EntityStore } from "./entity"; import type { EntityUuid, WebId } from "@blockprotocol/type-system"; @@ -16,10 +16,8 @@ const entityIdFor = (index: number) => `22222222-2222-4222-8222-${index.toString(16).padStart(12, "0")}` as EntityUuid, ); -/** Decode the EntityId the store recorded for `idx`, reading its join-map SAB directly - * (the same on-demand read the main thread does). */ const readMappedId = (store: EntityStore, idx: number) => - decodeEntityId(new Uint8Array(store.entityIdMap.raw, ID_HEADER_BYTES), idx); + decodeEntityId(new Uint8Array(store.lookupBuffer.raw, ID_HEADER_BYTES), idx); describe("EntityStore join map", () => { it("writes each EntityId into the map the instant it is interned", () => { @@ -27,8 +25,8 @@ describe("EntityStore join map", () => { const idA = entityIdFor(1); const idB = entityIdFor(2); - const [createdA, idxA] = store.tryInsert(idA); - const [createdB, idxB] = store.tryInsert(idB); + const [createdA, idxA] = store.insert(idA); + const [createdB, idxB] = store.insert(idB); expect(createdA).toBe(true); expect(createdB).toBe(true); @@ -40,9 +38,9 @@ describe("EntityStore join map", () => { it("re-interning an EntityId returns the same idx, map untouched", () => { const store = new EntityStore(); const idA = entityIdFor(1); - const [, idx] = store.tryInsert(idA); + const [, idx] = store.insert(idA); - const [createdAgain, idxAgain] = store.tryInsert(idA); + const [createdAgain, idxAgain] = store.insert(idA); expect(createdAgain).toBe(false); expect(idxAgain).toBe(idx); @@ -50,22 +48,21 @@ describe("EntityStore join map", () => { }); it("grows the map past its initial capacity, preserving earlier entries", () => { - const store = new EntityStore(); + const store = new EntityStore(() => {}); const first = entityIdFor(0); - store.tryInsert(first); + store.insert(first); - // Exceed the initial capacity (4096) so the map grows at least once. let lastId = first; let lastIdx = 0; for (let index = 1; index <= 5000; index++) { lastId = entityIdFor(index); - const [, idx] = store.tryInsert(lastId); + const [, idx] = store.insert(lastId); lastIdx = idx; } expect(store.size).toBe(5001); - expect(store.entityIdMap.capacity).toBeGreaterThanOrEqual(5001); - expect(readMappedId(store, 0)).toBe(first); // survived the grow(s) + expect(store.lookupBuffer.capacity).toBeGreaterThanOrEqual(5001); + expect(readMappedId(store, 0)).toBe(first); expect(readMappedId(store, lastIdx)).toBe(lastId); }); }); @@ -73,33 +70,31 @@ describe("EntityStore join map", () => { describe("EntityStore roots", () => { it("treats a freshly-interned entity as a frontier node (not a root)", () => { const store = new EntityStore(); - const [, idx] = store.tryInsert(entityIdFor(1)); + const [, idx] = store.insert(entityIdFor(1)); expect(store.isRoot(idx)).toBe(false); }); it("promotes an entity to a root, reporting the flip only once", () => { const store = new EntityStore(); - const [, idx] = store.tryInsert(entityIdFor(1)); + const [, idx] = store.insert(entityIdFor(1)); - expect(store.setRoot(idx)).toBe(true); + expect(store.insertRoot(idx)).toBe(true); expect(store.isRoot(idx)).toBe(true); - // Idempotent: promoting an existing root reports no flip and stays a root. - expect(store.setRoot(idx)).toBe(false); + expect(store.insertRoot(idx)).toBe(false); expect(store.isRoot(idx)).toBe(true); }); it("tracks root-ness independently per entity, including past the initial capacity", () => { - const store = new EntityStore(); - const [, idxA] = store.tryInsert(entityIdFor(1)); + const store = new EntityStore(() => {}); + const [, idxA] = store.insert(entityIdFor(1)); - // Intern well past the initial capacity (4096) so the root bitset has to grow. let highIdx = idxA; for (let index = 2; index <= 5000; index++) { - const [, idx] = store.tryInsert(entityIdFor(index)); + const [, idx] = store.insert(entityIdFor(index)); highIdx = idx; } - expect(store.setRoot(highIdx)).toBe(true); + expect(store.insertRoot(highIdx)).toBe(true); expect(store.isRoot(highIdx)).toBe(true); expect(store.isRoot(idxA)).toBe(false); }); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.ts new file mode 100644 index 00000000000..e0dbc6117fe --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/entity.ts @@ -0,0 +1,99 @@ +/** + * Entity interning and per-entity columnar storage. + * + * Column indices are kept in sync with the interner: each new entity + * gets a push on every column, so {@link EntityIndex} indexes into all + * of them. + */ +import { EntityIdBuffer } from "../buffers/entity-id-buffer"; +import { BitSet } from "../collections/bitset"; +import { Column } from "../collections/column"; +import { Interner } from "../collections/interner"; + +import type { EntityIndex, LabelId, TypeSetId } from "../../ids"; +import type { RepublishHandler } from "../buffers/growable-buffer"; +import type { EntityId } from "@blockprotocol/type-system"; + +const INITIAL_CAPACITY = 4096; + +export class EntityStore { + readonly #interner: Interner; + readonly #type: Column; + readonly #label: Column; + /** Query roots. Add-only: roots only grow as the frontier expands. */ + readonly #root: BitSet; + readonly #lookup: EntityIdBuffer; + + constructor(republish?: RepublishHandler) { + this.#interner = new Interner(); + + this.#type = new Column(Int32Array, INITIAL_CAPACITY); + this.#label = new Column(Int32Array, INITIAL_CAPACITY); + this.#root = BitSet.empty(INITIAL_CAPACITY); + this.#lookup = new EntityIdBuffer(INITIAL_CAPACITY, republish); + } + + get size(): number { + return this.#interner.size; + } + + /** The EntityIndex to EntityId shared buffer. */ + get lookupBuffer(): EntityIdBuffer { + return this.#lookup; + } + + /** Insert an entity if not already present. */ + insert(entityId: EntityId): [created: boolean, index: EntityIndex] { + const [created, index] = this.#interner.tryIntern(entityId); + + if (!created) { + return [false, index]; + } + + this.#lookup.ensureCapacity(index + 1); + this.#lookup.setId(index, entityId); + this.#type.push(-1); + this.#label.push(-1); + + return [true, index]; + } + + lookup(entityId: EntityId): EntityIndex | undefined { + return this.#interner.tryGet(entityId); + } + + get(index: EntityIndex): EntityId | undefined { + return this.#interner.getValue(index); + } + + setTypeSet(index: EntityIndex, type: TypeSetId): void { + this.#type.set(index, type); + } + + /** The entity's type-set group, or -1 if unassigned. */ + getTypeSet(index: EntityIndex): TypeSetId | -1 { + return this.#type.get(index); + } + + setLabel(index: EntityIndex, label: LabelId): void { + this.#label.set(index, label); + } + + getLabel(index: EntityIndex): LabelId | -1 { + return this.#label.get(index); + } + + isRoot(index: EntityIndex): boolean { + return this.#root.has(index); + } + + /** Promote to root. Returns whether it flipped (was previously frontier). */ + insertRoot(index: EntityIndex): boolean { + if (this.#root.has(index)) { + return false; + } + + this.#root.add(index); + return true; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts new file mode 100644 index 00000000000..3aa14342165 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts @@ -0,0 +1,155 @@ +/** + * Link storage: columnar arrays for endpoints, link type, and entity + * index, plus an adjacency index and a pending-endpoint queue for + * frontier resolution. + */ +import { LinkId } from "../../ids"; +import { Column } from "../collections/column"; + +import type { EntityIndex, TypeSetId } from "../../ids"; +import type { EntityId } from "@blockprotocol/type-system"; + +const INITIAL_CAPACITY = 1024; + +export interface LinkEndpoint { + readonly linkId: LinkId; + readonly otherId: EntityIndex; + readonly typeSetId: TypeSetId; + readonly direction: "out" | "in"; +} + +export class LinkStore { + readonly #left: Column; + readonly #right: Column; + readonly #type: Column; + readonly #entity: Column; + readonly #pending: Map; + readonly #adjacency: Map; + + constructor() { + this.#left = new Column(Int32Array, INITIAL_CAPACITY); + this.#right = new Column(Int32Array, INITIAL_CAPACITY); + this.#type = new Column(Uint32Array, INITIAL_CAPACITY); + this.#entity = new Column(Uint32Array, INITIAL_CAPACITY); + this.#pending = new Map(); + this.#adjacency = new Map(); + } + + get count(): number { + return this.#left.length; + } + + /** + * Record a link. Endpoints are -1 when the target entity hasn't been + * ingested yet (frontier case). + */ + insert( + left: EntityIndex | -1, + right: EntityIndex | -1, + typeSetId: TypeSetId, + entityIndex: EntityIndex, + ): LinkId { + this.#left.push(left); + this.#right.push(right); + this.#type.push(typeSetId); + this.#entity.push(entityIndex); + + const id = LinkId(this.#left.length - 1); + + if (left !== -1) { + this.#addToAdjacency(left, id); + } + if (right !== -1) { + this.#addToAdjacency(right, id); + } + + return id; + } + + #addToAdjacency(entityIndex: number, linkId: LinkId): void { + let list = this.#adjacency.get(entityIndex); + if (!list) { + list = []; + this.#adjacency.set(entityIndex, list); + } + list.push(linkId); + } + + addPending(endpointId: EntityId, linkId: LinkId): void { + const pending = this.#pending.get(endpointId) ?? []; + pending.push(linkId); + this.#pending.set(endpointId, pending); + } + + /** Remove and return pending links for an endpoint, or `undefined` if none. */ + takePending(endpointId: EntityId): LinkId[] | undefined { + const pending = this.#pending.get(endpointId); + if (pending) { + this.#pending.delete(endpointId); + } + return pending; + } + + /** Resolve a previously-pending endpoint and add it to the adjacency index. */ + resolveEndpoint( + linkId: LinkId, + side: "left" | "right", + entityIndex: EntityIndex, + ): void { + if (side === "left") { + this.#left.set(linkId, entityIndex); + } else { + this.#right.set(linkId, entityIndex); + } + this.#addToAdjacency(entityIndex, linkId); + } + + getLeft(linkId: number): EntityIndex | -1 { + return this.#left.get(linkId); + } + + getRight(linkId: number): EntityIndex | -1 { + return this.#right.get(linkId); + } + + getTypeSetId(linkId: number): TypeSetId { + return this.#type.get(linkId); + } + + /** The link's own entity index (a link is itself an entity). */ + getEntityIndex(linkId: number): EntityIndex { + return this.#entity.get(linkId); + } + + /** All links touching an entity, with direction. O(degree). */ + linksFor(entityIndex: EntityIndex): LinkEndpoint[] { + const linkIds = this.#adjacency.get(entityIndex); + if (!linkIds) { + return []; + } + + const result: LinkEndpoint[] = []; + for (const linkId of linkIds) { + const left = this.#left.get(linkId); + const right = this.#right.get(linkId); + + if (left === entityIndex && right !== -1) { + result.push({ + linkId, + otherId: right, + typeSetId: this.#type.get(linkId), + direction: "out", + }); + } else if (right === entityIndex && left !== -1) { + result.push({ + linkId, + otherId: left, + typeSetId: this.#type.get(linkId), + direction: "in", + }); + } + } + + return result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts similarity index 60% rename from apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts index 7bc7fa3aa0c..82f53b420fa 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/property-store.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts @@ -7,20 +7,19 @@ */ import { Interner } from "../collections/interner"; -import type { EntityIdx } from "../../ids"; +import type { EntityIndex } from "../../ids"; import type { PropertySchemaEntry } from "../protocol"; import type { PropertyObject } from "@blockprotocol/type-system"; /** Interned index of a distinct `(baseUrl, formatted-value)` feature. */ -export type FeatureIdx = number; +export type FeatureId = number; /** Interned index of a property base URL that carries numeric/date values. */ -export type NumericKeyIdx = number; +export type NumericKeyId = number; /** Whether a numeric property reads as a plain number or an (epoch-ms) date. */ export type NumericKind = "number" | "date"; -/** Cap a value's serialized length so a free-text property can't bloat the interner. */ const MAX_VALUE_CHARS = 64; /** Strict ISO-8601 date/datetime. Rejects bare numbers and partial strings that `Date.parse` accepts. */ @@ -29,18 +28,15 @@ const ISO_DATE_RE = interface FeatureInfo { readonly baseUrl: string; - /** The value as it appears in a label: `"foo"`, `123`, `true`, `3 items`, `present`. */ readonly display: string; } -/** What a feature renders to in a label. */ export interface FeatureLabel { readonly baseUrl: string; readonly title: string; readonly display: string; } -/** A raw numeric reading: a plain number, or a date as epoch milliseconds. */ interface NumericReading { readonly value: number; readonly kind: NumericKind; @@ -60,8 +56,6 @@ function truncate(value: string): string { */ function formatFeatureValue(value: unknown): string | undefined { if (typeof value === "string") { - // Collapse whitespace runs (and trim): folds fixed-width padding like "CNTM " into - // "CNTM" so padded/unpadded values share a feature, and keeps a value on one label line. const collapsed = value.replace(/\s+/gu, " ").trim(); return collapsed.length === 0 ? undefined : `"${truncate(collapsed)}"`; } @@ -109,24 +103,33 @@ function slugTitleFromBaseUrl(baseUrl: string): string { } export class PropertyStore { - readonly #features: Interner = new Interner(); - readonly #featureInfo: FeatureInfo[] = []; - /** Per-entity sorted, de-duplicated feature indices, indexed by EntityIdx (dense, additive). */ - readonly #entityFeatures: (Int32Array | undefined)[] = []; - /** baseUrl -> human title, from {@link PropertySchemaEntry}. */ - readonly #titles: Map = new Map(); - - /** baseUrl -> NumericKeyIdx, for properties that carry numeric/date values. */ - readonly #numericKeys: Interner = new Interner(); - /** Parallel to the interner: per-key base URL and kind (number vs date). */ - readonly #numericKeyBaseUrl: string[] = []; - readonly #numericKeyKind: NumericKind[] = []; + readonly #features: Interner; + readonly #featureInfo: FeatureInfo[]; + readonly #entityFeatures: (Int32Array | undefined)[]; + readonly #titles: Map; + + readonly #numericKeys: Interner; + readonly #numericKeyBaseUrl: string[]; + readonly #numericKeyKind: NumericKind[]; // Raw (not interned): range bucketing depends on the live distribution of // a cluster's members, so values can't be pre-interned. - readonly #entityNumericKeys: (Int32Array | undefined)[] = []; - readonly #entityNumericValues: (Float64Array | undefined)[] = []; + readonly #entityNumericKeys: (Int32Array | undefined)[]; + readonly #entityNumericValues: (Float64Array | undefined)[]; - /** Register property display titles (additive; later batches add, never overwrite). */ + constructor() { + this.#features = new Interner(); + this.#featureInfo = []; + this.#entityFeatures = []; + this.#titles = new Map(); + + this.#numericKeys = new Interner(); + this.#numericKeyBaseUrl = []; + this.#numericKeyKind = []; + this.#entityNumericKeys = []; + this.#entityNumericValues = []; + } + + /** Register property display titles. Additive; later batches never overwrite. */ registerTitles(entries: readonly PropertySchemaEntry[]): void { for (const { baseUrl, title } of entries) { if (title && !this.#titles.has(baseUrl)) { @@ -135,7 +138,7 @@ export class PropertyStore { } } - /** Human title for a base URL: the registered property-type title, else a slug fallback. */ + /** Human title for a base URL, falling back to a slug-derived title. */ title(baseUrl: string): string { return this.#titles.get(baseUrl) ?? slugTitleFromBaseUrl(baseUrl); } @@ -145,58 +148,58 @@ export class PropertyStore { * * No-op when the entity has no labelable property. */ - ingest(entityIdx: EntityIdx, properties: PropertyObject | undefined): void { + ingest(index: EntityIndex, properties: PropertyObject | undefined): void { if (!properties) { return; } - const featureIdxs = new Set(); - const numericKeyIdxs: NumericKeyIdx[] = []; + const featureIds = new Set(); + const numericKeyIds: NumericKeyId[] = []; const numericValues: number[] = []; for (const [baseUrl, value] of Object.entries(properties)) { const display = formatFeatureValue(value); if (display !== undefined) { - const [created, featureIdx] = this.#features.tryIntern( + const [created, featureId] = this.#features.tryIntern( `${baseUrl}\u0000${display}`, ); if (created) { this.#featureInfo.push({ baseUrl, display }); } - featureIdxs.add(featureIdx); + featureIds.add(featureId); } const reading = numericReading(value); if (reading) { - const [created, keyIdx] = this.#numericKeys.tryIntern(baseUrl); + const [created, keyId] = this.#numericKeys.tryIntern(baseUrl); if (created) { - this.#numericKeyBaseUrl[keyIdx] = baseUrl; - this.#numericKeyKind[keyIdx] = reading.kind; + this.#numericKeyBaseUrl[keyId] = baseUrl; + this.#numericKeyKind[keyId] = reading.kind; } - numericKeyIdxs.push(keyIdx); + numericKeyIds.push(keyId); numericValues.push(reading.value); } } - if (featureIdxs.size > 0) { - this.#entityFeatures[entityIdx] = Int32Array.from( - [...featureIdxs].sort((left, right) => left - right), + if (featureIds.size > 0) { + this.#entityFeatures[index] = Int32Array.from( + [...featureIds].sort((left, right) => left - right), ); } - if (numericKeyIdxs.length > 0) { - this.#entityNumericKeys[entityIdx] = Int32Array.from(numericKeyIdxs); - this.#entityNumericValues[entityIdx] = Float64Array.from(numericValues); + if (numericKeyIds.length > 0) { + this.#entityNumericKeys[index] = Int32Array.from(numericKeyIds); + this.#entityNumericValues[index] = Float64Array.from(numericValues); } } /** Sorted feature indices for an entity, or `undefined` if it has none. */ - featuresOf(entityIdx: EntityIdx): Int32Array | undefined { - return this.#entityFeatures[entityIdx]; + featuresOf(index: EntityIndex): Int32Array | undefined { + return this.#entityFeatures[index]; } /** Resolve a feature index to its display label. */ - describe(featureIdx: FeatureIdx): FeatureLabel | undefined { - const info = this.#featureInfo[featureIdx]; + describe(featureId: FeatureId): FeatureLabel | undefined { + const info = this.#featureInfo[featureId]; if (!info) { return undefined; } @@ -208,20 +211,20 @@ export class PropertyStore { } /** Numeric property key indices, parallel to {@link numericValuesOf}. */ - numericKeysOf(entityIdx: EntityIdx): Int32Array | undefined { - return this.#entityNumericKeys[entityIdx]; + numericKeysOf(index: EntityIndex): Int32Array | undefined { + return this.#entityNumericKeys[index]; } /** Raw numeric values (numbers, or dates as epoch ms). */ - numericValuesOf(entityIdx: EntityIdx): Float64Array | undefined { - return this.#entityNumericValues[entityIdx]; + numericValuesOf(index: EntityIndex): Float64Array | undefined { + return this.#entityNumericValues[index]; } - numericBaseUrl(keyIdx: NumericKeyIdx): string | undefined { - return this.#numericKeyBaseUrl[keyIdx]; + numericBaseUrl(keyId: NumericKeyId): string | undefined { + return this.#numericKeyBaseUrl[keyId]; } - numericKind(keyIdx: NumericKeyIdx): NumericKind { - return this.#numericKeyKind[keyIdx] ?? "number"; + numericKind(keyId: NumericKeyId): NumericKind { + return this.#numericKeyKind[keyId] ?? "number"; } } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.test.ts similarity index 76% rename from apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.test.ts index f1f14993f65..b40e1a2f9df 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.test.ts @@ -14,9 +14,6 @@ describe("TypeRegistry root resolution", () => { const customer = url("customer"); const company = url("company"); - // Customer is listed first, so processing its `allOfRefs` interns `company` - // as a bare ref — giving the parent a HIGHER idx than the child. The roots - // computation must not depend on that ordering. const schemas: TypeSchemaEntry[] = [ { url: customer, title: "Customer", allOfRefs: [company] }, { url: company, title: "Company", allOfRefs: [] }, @@ -25,12 +22,12 @@ describe("TypeRegistry root resolution", () => { const registry = new TypeRegistry(); registry.registerAll(schemas); - const customerIdx = registry.intern(customer); - const companyIdx = registry.intern(company); + const customerId = registry.intern(customer); + const companyId = registry.intern(company); - expect(customerIdx).toBeLessThan(companyIdx); - expect(registry.get(companyIdx)?.rootIdxs).toEqual([companyIdx]); - expect(registry.get(customerIdx)?.rootIdxs).toEqual([companyIdx]); + expect(customerId).toBeLessThan(companyId); + expect(registry.get(companyId)?.rootIds).toEqual([companyId]); + expect(registry.get(customerId)?.rootIds).toEqual([companyId]); }); it("resolves the SAME root for siblings so they bucket together", () => { @@ -47,12 +44,12 @@ describe("TypeRegistry root resolution", () => { const registry = new TypeRegistry(); registry.registerAll(schemas); - const companyIdx = registry.intern(company); - expect(registry.get(registry.intern(customer))?.rootIdxs).toEqual([ - companyIdx, + const companyId = registry.intern(company); + expect(registry.get(registry.intern(customer))?.rootIds).toEqual([ + companyId, ]); - expect(registry.get(registry.intern(supplier))?.rootIdxs).toEqual([ - companyIdx, + expect(registry.get(registry.intern(supplier))?.rootIds).toEqual([ + companyId, ]); }); @@ -61,8 +58,6 @@ describe("TypeRegistry root resolution", () => { const company = url("company"); const actor = url("actor"); - // Transitive over-approximation (child points at ALL ancestors) plus a - // deeper chain — the topmost parentless type must win. const schemas: TypeSchemaEntry[] = [ { url: customer, title: "Customer", allOfRefs: [company, actor] }, { url: company, title: "Company", allOfRefs: [actor] }, @@ -72,10 +67,8 @@ describe("TypeRegistry root resolution", () => { const registry = new TypeRegistry(); registry.registerAll(schemas); - const actorIdx = registry.intern(actor); - expect(registry.get(registry.intern(customer))?.rootIdxs).toEqual([ - actorIdx, - ]); + const actorId = registry.intern(actor); + expect(registry.get(registry.intern(customer))?.rootIds).toEqual([actorId]); }); }); @@ -85,8 +78,6 @@ describe("TypeRegistry colour slots", () => { const supplier = url("supplier"); const company = url("company"); - // Arrival order (customer, supplier, company) differs from sorted order - // (company, customer, supplier) — the slot follows the SORT, not arrival. const registry = new TypeRegistry(); registry.registerAll([ { url: customer, title: "Customer", allOfRefs: [company] }, @@ -113,8 +104,6 @@ describe("TypeRegistry colour slots", () => { { url: actor, title: "Actor", allOfRefs: [] }, ]); - // The first batch keeps its slot; the second is appended, sorted within - // itself (actor < person), so existing colours never shift on expansion. expect(registry.colorSlot(registry.intern(company))).toBe(companySlot); expect(registry.colorSlot(registry.intern(actor))).toBe(1); expect(registry.colorSlot(registry.intern(person))).toBe(2); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts new file mode 100644 index 00000000000..d0ab13ef602 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts @@ -0,0 +1,234 @@ +/** + * Type registry: VersionedUrl interning, per-type metadata, ancestor + * closures, and stable colour slot assignment. + */ +import { extractBaseUrl } from "@blockprotocol/type-system"; + +import { BitSet } from "../collections/bitset"; +import { Interner } from "../collections/interner"; + +import type { TypeId } from "../../ids"; +import type { TypeSchemaEntry } from "../protocol"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +export interface TypeInfo { + readonly id: TypeId; + readonly url: VersionedUrl; + readonly title: string; + /** For a link type, the inverse (target to source) title. */ + readonly inverseTitle?: string; + readonly icon?: string; + readonly parentIds: readonly TypeId[]; + readonly ancestorClosure: BitSet; + readonly depth: number; + readonly rootIds: readonly TypeId[]; +} + +export class TypeRegistry { + readonly #interner: Interner; + readonly #types: (TypeInfo | undefined)[]; + /** + * Stable colour slot per type. Sorted by base URL within each batch + * and append-only, so a type's colour is deterministic across reloads. + */ + readonly #colorSlots: Map; + #nextColorSlot: number; + + constructor() { + this.#interner = new Interner(); + this.#types = []; + this.#colorSlots = new Map(); + this.#nextColorSlot = 0; + } + + get size(): number { + return this.#interner.size; + } + + intern(url: VersionedUrl): TypeId { + return this.#interner.intern(url); + } + + get(id: TypeId): TypeInfo | undefined { + return this.#types[id]; + } + + getUrl(id: TypeId): VersionedUrl | undefined { + return id < this.#interner.size ? this.#interner.getValue(id) : undefined; + } + + /** Stable colour slot for a type, or `undefined` if not yet registered. */ + colorSlot(id: TypeId): number | undefined { + return this.#colorSlots.get(id); + } + + /** + * One line per registered type. An interned parent without a schema + * shows as `#(unreg)`. + */ + debugDump(): string { + const name = (id: TypeId): string => + this.#types[id]?.title ?? `#${id}(unreg)`; + const lines: string[] = []; + for (const info of this.#types) { + if (!info) { + continue; + } + const parents = info.parentIds.map(name).join(", "); + const roots = info.rootIds.map(name).join(", "); + lines.push( + `#${info.id} "${info.title}" parents=[${parents}] roots=[${roots}]`, + ); + } + return lines.join("\n"); + } + + /** + * Register type schemas. Two passes: first intern everything (so parent + * refs resolve), then build ancestor closures. + */ + registerAll(schemas: readonly TypeSchemaEntry[]): void { + const newlyRegistered: TypeId[] = []; + + for (const schema of schemas) { + const id = this.#interner.intern(schema.url); + + if (this.#types[id]) { + continue; + } + + newlyRegistered.push(id); + const parentIds = schema.allOfRefs.map((ref) => + this.#interner.intern(ref), + ); + + this.#types[id] = { + id, + url: schema.url, + title: schema.title, + inverseTitle: schema.inverseTitle, + icon: schema.icon, + parentIds, + ancestorClosure: BitSet.empty(this.#interner.size), + depth: 0, + rootIds: [], + }; + } + + if (newlyRegistered.length > 0) { + this.#assignColorSlots(newlyRegistered); + this.#computeClosures(); + } + } + + #assignColorSlots(newlyRegistered: readonly TypeId[]): void { + const sorted = [...newlyRegistered].sort((left, right) => { + const leftUrl = extractBaseUrl(this.#types[left]!.url); + const rightUrl = extractBaseUrl(this.#types[right]!.url); + if (leftUrl < rightUrl) { + return -1; + } + return leftUrl > rightUrl ? 1 : 0; + }); + for (const id of sorted) { + this.#colorSlots.set(id, this.#nextColorSlot); + this.#nextColorSlot += 1; + } + } + + #computeClosures(): void { + const universeSize = this.#interner.size; + + const closures = new Map>(); + const depths = new Map(); + const roots = new Map(); + + for (const info of this.#types) { + if (!info) { + continue; + } + closures.set(info.id, BitSet.fromBit(universeSize, info.id)); + depths.set(info.id, 0); + roots.set(info.id, []); + } + + // Fixed-point: closures, depths, and roots propagate up the DAG until stable. + // Typically converges in 2 to 3 passes for shallow hierarchies. + let changed = true; + while (changed) { + changed = false; + + for (const info of this.#types) { + if (!info) { + continue; + } + + // Ancestor closure: self + union of parent closures. + const current = closures.get(info.id)!; + let merged = current; + + for (const parentId of info.parentIds) { + const parentClosure = closures.get(parentId); + if (parentClosure) { + const next = merged.or(parentClosure); + if (next.cardinality > merged.cardinality) { + merged = next; + changed = true; + } + } + } + + closures.set(info.id, merged); + + // Depth: 1 + max parent depth. + if (info.parentIds.length > 0) { + const parentDepth = Math.max( + ...info.parentIds.map((parentId) => depths.get(parentId) ?? 0), + ); + const newDepth = parentDepth + 1; + if (newDepth > (depths.get(info.id) ?? 0)) { + depths.set(info.id, newDepth); + changed = true; + } + } + + // Roots: a parentless type is its own root; otherwise inherit the + // union of parent roots. Must be inside the fixed-point because + // parents are interned lazily from allOfRefs (child-before-parent + // is common), so a forward pass would read uncomputed parent roots. + if (info.parentIds.length === 0) { + if (roots.get(info.id)!.length === 0) { + roots.set(info.id, [info.id]); + changed = true; + } + } else { + const rootSet = new Set(roots.get(info.id)); + const before = rootSet.size; + for (const parentId of info.parentIds) { + for (const rootId of roots.get(parentId) ?? []) { + rootSet.add(rootId); + } + } + if (rootSet.size > before) { + roots.set(info.id, [...rootSet]); + changed = true; + } + } + } + } + + for (let i = 0; i < this.#types.length; i++) { + const info = this.#types[i]; + if (!info) { + continue; + } + + this.#types[i] = { + ...info, + ancestorClosure: closures.get(info.id) ?? info.ancestorClosure, + depth: depths.get(info.id) ?? 0, + rootIds: roots.get(info.id) ?? [], + }; + } + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-set.ts similarity index 62% rename from apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-set.ts index 32220cdd206..a26ec95ab5e 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-set-store.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-set.ts @@ -1,11 +1,18 @@ +/** + * Type-set grouping: entities that share the exact same set of direct + * entity types belong to one {@link TypeSetGroup}. + */ import { ClusterId, TypeSetKey } from "../../ids"; import { BitSet } from "../collections/bitset"; +import { Column } from "../collections/column"; import { Interner } from "../collections/interner"; -import type { EntityIdx, TypeIdx, TypeSetIdx } from "../../ids"; +import type { EntityIndex, TypeId, TypeSetId } from "../../ids"; import type { ReadonlySortedSet } from "../collections/readonly-sorted-set"; import type { TypeRegistry } from "./type-registry"; +const INITIAL_CAPACITY = 256; + /** * A group of entities that share the exact same set of direct entity types. * @@ -16,47 +23,48 @@ import type { TypeRegistry } from "./type-registry"; */ export class TypeSetGroup { readonly key: TypeSetKey; - readonly idx: TypeSetIdx; - readonly directTypeIdxs: ReadonlySortedSet; + readonly id: TypeSetId; + readonly directTypeIds: ReadonlySortedSet; /** Cluster ID used when this group is large enough to stand alone. */ readonly standaloneClusterId: ClusterId; - #entityIdxs: EntityIdx[] = []; + readonly #entities: Column; /** Union of ancestor closures of all direct types. Used for merge-target lookup. */ - #closure: BitSet; + #closure: BitSet; /** Incremented on entity add; used for change detection. */ #version = 0; /** * The cluster that currently owns this group's entities: - * {@link standaloneClusterId} when standalone, or a merge target's ID when - * the group is too small. + * {@link standaloneClusterId} when standalone, or a merge target's ID + * when the group is too small. */ #assignedClusterId: ClusterId; #isStandalone = false; constructor( key: TypeSetKey, - idx: TypeSetIdx, - directTypeIdxs: ReadonlySortedSet, + id: TypeSetId, + directTypeIds: ReadonlySortedSet, typeUniverseSize: number, ) { this.key = key; - this.idx = idx; - this.directTypeIdxs = directTypeIdxs; + this.id = id; + this.directTypeIds = directTypeIds; this.standaloneClusterId = ClusterId(`cluster:type:${key}`); this.#assignedClusterId = this.standaloneClusterId; this.#closure = BitSet.empty(typeUniverseSize); + this.#entities = new Column(Int32Array, INITIAL_CAPACITY); } get count(): number { - return this.#entityIdxs.length; + return this.#entities.length; } - get entityIdxs(): readonly EntityIdx[] { - return this.#entityIdxs; + get entities(): Column { + return this.#entities; } - get closure(): BitSet { + get closure(): BitSet { return this.#closure; } @@ -80,17 +88,17 @@ export class TypeSetGroup { this.#isStandalone = value; } - addEntity(entityIdx: EntityIdx): void { - this.#entityIdxs.push(entityIdx); + addEntity(index: EntityIndex): void { + this.#entities.push(index); this.#version++; } /** Recompute the ancestor closure from the current type registry. */ recomputeClosure(types: TypeRegistry): void { - let closure = BitSet.empty(types.size); + let closure = BitSet.empty(types.size); - for (const typeIdx of this.directTypeIdxs) { - const info = types.get(typeIdx); + for (const typeId of this.directTypeIds) { + const info = types.get(typeId); if (info) { closure = closure.or(info.ancestorClosure); } @@ -100,13 +108,15 @@ export class TypeSetGroup { } } -/** - * Manages type-set groups: collections of entities that share - * the same set of direct entity types. - */ +/** Manages {@link TypeSetGroup}s, interning by their canonical type-set key. */ export class TypeSetStore { - readonly #interner: Interner = new Interner(); - readonly #groups: Map = new Map(); + readonly #interner: Interner; + readonly #groups: Map; + + constructor() { + this.#interner = new Interner(); + this.#groups = new Map(); + } get size(): number { return this.#groups.size; @@ -116,23 +126,23 @@ export class TypeSetStore { return this.#groups.get(key); } - getByIdx(idx: TypeSetIdx): TypeSetGroup | undefined { - const key = this.#interner.getValue(idx); + getById(id: TypeSetId): TypeSetGroup | undefined { + const key = this.#interner.getValue(id); return this.#groups.get(key); } getOrCreate( - directTypeIdxs: ReadonlySortedSet, + directTypeIds: ReadonlySortedSet, typeUniverseSize: number, ): TypeSetGroup { - const key = TypeSetKey(directTypeIdxs.items.join(",")); + const key = TypeSetKey(directTypeIds.items.join(",")); const existing = this.#groups.get(key); if (existing) { return existing; } - const idx = this.#interner.intern(key); - const group = new TypeSetGroup(key, idx, directTypeIdxs, typeUniverseSize); + const id = this.#interner.intern(key); + const group = new TypeSetGroup(key, id, directTypeIds, typeUniverseSize); this.#groups.set(key, group); return group; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts deleted file mode 100644 index b5259459c43..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/entity-store.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { EntityIdBuffer } from "../buffers/entity-id-buffer"; -import { BitSet } from "../collections/bitset"; -import { Column } from "../collections/column"; -import { Interner } from "../collections/interner"; - -import type { EntityIdx, TypeSetIdx } from "../../ids"; -import type { RepublishHandler } from "../buffers/growable-buffer"; -import type { EntityId } from "@blockprotocol/type-system"; - -/** Starting slot count for the columns + the EntityId map (all grow geometrically). */ -const INITIAL_CAPACITY = 4096; - -/** - * Entity ID interning and per-entity columnar storage. - * - * Column indices are kept in sync with the interner: each new entity - * gets a push on every column, so EntityIdx indexes into all of them. - */ -export class EntityStore { - readonly #interner: Interner = new Interner(); - readonly #typeGroupIdx: Column = new Column( - Uint32Array, - INITIAL_CAPACITY, - ); - - readonly #labelIdx: Column = new Column(Int32Array, 4096); - - /** Query roots. Add-only: roots only grow as the frontier expands. */ - readonly #roots: BitSet = BitSet.empty(INITIAL_CAPACITY); - - /** EntityIdx to EntityId shared buffer. */ - readonly #entityIdMap: EntityIdBuffer; - - constructor(republish?: RepublishHandler) { - this.#entityIdMap = new EntityIdBuffer(INITIAL_CAPACITY, republish); - } - - get size(): number { - return this.#interner.size; - } - - get entityIdMap(): EntityIdBuffer { - return this.#entityIdMap; - } - - /** Insert an entity if not already present. Returns whether it was newly created. */ - tryInsert(entityId: EntityId): [created: boolean, idx: EntityIdx] { - const [created, idx] = this.#interner.tryIntern(entityId); - if (created) { - if (idx >= this.#entityIdMap.capacity) { - this.#entityIdMap.ensureCapacity( - Math.max(idx + 1, this.#entityIdMap.capacity * 2), - ); - } - this.#entityIdMap.setId(idx, entityId); - this.#typeGroupIdx.push(-1); - this.#labelIdx.push(-1); - } - return [created, idx]; - } - - tryGet(entityId: EntityId): EntityIdx | undefined { - return this.#interner.tryGet(entityId); - } - - getEntityId(idx: EntityIdx): EntityId { - return this.#interner.getValue(idx); - } - - setTypeGroup(entityIdx: EntityIdx, typeSetIdx: TypeSetIdx): void { - this.#typeGroupIdx.set(entityIdx, typeSetIdx); - } - - /** The type-set group an entity belongs to, or -1 if it's a link/unassigned. */ - getTypeGroup(entityIdx: EntityIdx): TypeSetIdx | -1 { - return this.#typeGroupIdx.get(entityIdx); - } - - setLabel(entityIdx: EntityIdx, labelIdx: number): void { - this.#labelIdx.set(entityIdx, labelIdx); - } - - /** Whether this entity is a query root (vs frontier). */ - isRoot(entityIdx: EntityIdx): boolean { - return this.#roots.has(entityIdx); - } - - /** Promote an entity to a root. Returns whether it flipped (was previously frontier). */ - setRoot(entityIdx: EntityIdx): boolean { - if (this.#roots.has(entityIdx)) { - return false; - } - - this.#roots.add(entityIdx); - return true; - } -} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts deleted file mode 100644 index b8b6de00331..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Store-ingestion hot path (runs once per ingested entity; the whole page-load - * stream flows through it). Isolates the per-entity type-set keying, the entity - * interner, and the link adjacency + `linksForEntity` allocation, so a regression - * in any of them shows up here without the layout/commit noise on top. - * - * Run: `cd apps/hash-frontend && ../../node_modules/.bin/vitest bench --run \ - * src/pages/shared/graph-visualizer-2/worker/stores/ingestion.bench.ts` - */ -// eslint-disable-next-line import/no-extraneous-dependencies -import { bench, describe } from "vitest"; - -import { EntityIdx, TypeIdx, TypeSetIdx } from "../../ids"; -import { benchEntityId, buildCommunityInputs } from "../bench-fixtures"; -import { ReadonlySortedSet } from "../collections/readonly-sorted-set"; -import { EntityStore } from "./entity-store"; -import { TypeSetStore } from "./type-set-store"; - -import type { GraphShape } from "../bench-fixtures"; - -const compareTypeIdx = (lhs: TypeIdx, rhs: TypeIdx): number => lhs - rhs; - -interface Case { - readonly label: string; - readonly shape: GraphShape; -} - -const CASES: readonly Case[] = [ - { - label: "small (1k nodes / 2k links)", - shape: { - nodeCount: 1_000, - linkCount: 2_000, - typeCount: 12, - hubCount: 20, - rootFraction: 1, - seed: 1, - }, - }, - { - label: "medium (5k nodes / 10k links)", - shape: { - nodeCount: 5_000, - linkCount: 10_000, - typeCount: 24, - hubCount: 40, - rootFraction: 1, - seed: 2, - }, - }, - { - label: "large (20k nodes / 40k links)", - shape: { - nodeCount: 20_000, - linkCount: 40_000, - typeCount: 48, - hubCount: 80, - rootFraction: 1, - seed: 3, - }, - }, -]; - -/** A representative per-node direct type index list (mostly one type, some two). */ -function typeIdxsFor(nodeIndex: number, typeCount: number): TypeIdx[] { - const primary = TypeIdx(nodeIndex % typeCount); - if (nodeIndex % 5 === 0) { - return [primary, TypeIdx((nodeIndex * 7 + 1) % typeCount)]; - } - return [primary]; -} - -for (const { label, shape } of CASES) { - describe(`type-set keying: ${label}`, () => { - // `ingestBatch` builds a ReadonlySortedSet + calls getOrCreate ONCE in - // #peekGroup and AGAIN in insertNodeEntity for every node entity, so the - // per-node keying cost below is paid twice per streamed node. - bench("ReadonlySortedSet + getOrCreate once per node", () => { - const store = new TypeSetStore(); - for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { - const set = new ReadonlySortedSet( - typeIdxsFor(nodeIndex, shape.typeCount), - compareTypeIdx, - ); - store.getOrCreate(set, shape.typeCount); - } - }); - - bench("ReadonlySortedSet + getOrCreate twice per node (peek + insert)", () => { - const store = new TypeSetStore(); - for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { - const peekSet = new ReadonlySortedSet( - typeIdxsFor(nodeIndex, shape.typeCount), - compareTypeIdx, - ); - store.getOrCreate(peekSet, shape.typeCount); - const insertSet = new ReadonlySortedSet( - typeIdxsFor(nodeIndex, shape.typeCount), - compareTypeIdx, - ); - store.getOrCreate(insertSet, shape.typeCount); - } - }); - }); - - describe(`entity interning: ${label}`, () => { - bench("EntityStore.tryInsert per node", () => { - const store = new EntityStore(); - for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { - const [, idx] = store.tryInsert(benchEntityId(nodeIndex)); - store.setTypeGroup(idx, TypeSetIdx(nodeIndex % shape.typeCount)); - } - }); - }); - - describe(`link adjacency: ${label}`, () => { - // linksForEntity allocates a fresh LinkEndpoint[] (one object literal per - // incident link) on EVERY call, and several hot paths call it just to read - // `.length` (degree). This bench sweeps every entity's adjacency once, the - // shape of #seedFlatNodes / #buildEntityFanOut / community feature scans. - bench("build LinkStore + linksForEntity over all nodes", () => { - const { links } = buildCommunityInputs(shape); - let degreeSum = 0; - for (let nodeIndex = 0; nodeIndex < shape.nodeCount; nodeIndex++) { - degreeSum += links.linksForEntity(EntityIdx(nodeIndex)).length; - } - if (degreeSum < 0) { - throw new Error("unreachable"); - } - }); - }); -} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts deleted file mode 100644 index 2073f76fb5f..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/link-store.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { LinkIdx } from "../../ids"; -import { Column } from "../collections/column"; - -import type { EntityIdx, TypeSetIdx } from "../../ids"; -import type { EntityId } from "@blockprotocol/type-system"; - -export interface LinkEndpoint { - readonly linkIdx: number; - readonly otherIdx: EntityIdx; - readonly typeSetIdx: TypeSetIdx; - readonly direction: "out" | "in"; -} - -/** - * Link storage: columnar arrays for endpoints, link type, and - * entity index. Tracks pending links whose endpoints haven't - * been ingested yet. - */ -export class LinkStore { - readonly #leftIdx: Column = new Column( - Int32Array, - 1024, - ); - - readonly #rightIdx: Column = new Column( - Int32Array, - 1024, - ); - - readonly #typeIdx: Column = new Column( - Uint32Array, - 1024, - ); - - readonly #entityIdIdx: Column = new Column( - Uint32Array, - 1024, - ); - - readonly #pendingByEndpoint: Map = new Map(); - - // Adjacency index: entityIdx -> list of link indices touching that entity. - readonly #adjacency: Map = new Map(); - - get count(): number { - return this.#leftIdx.length; - } - - /** - * Record a link. Endpoints are -1 when the target entity - * hasn't been ingested yet (frontier case). - */ - insert( - leftIdx: EntityIdx | -1, - rightIdx: EntityIdx | -1, - typeSetIdx: TypeSetIdx, - linkEntityIdx: EntityIdx, - ): LinkIdx { - this.#leftIdx.push(leftIdx); - this.#rightIdx.push(rightIdx); - this.#typeIdx.push(typeSetIdx); - this.#entityIdIdx.push(linkEntityIdx); - - const linkIdx = LinkIdx(this.#leftIdx.length - 1); - - if (leftIdx !== -1) { - this.#addToAdjacency(leftIdx, linkIdx); - } - if (rightIdx !== -1) { - this.#addToAdjacency(rightIdx, linkIdx); - } - - return linkIdx; - } - - #addToAdjacency(entityIdx: number, linkIdx: LinkIdx): void { - let list = this.#adjacency.get(entityIdx); - if (!list) { - list = []; - this.#adjacency.set(entityIdx, list); - } - list.push(linkIdx); - } - - addPending(endpointId: EntityId, linkIdx: LinkIdx): void { - const pending = this.#pendingByEndpoint.get(endpointId) ?? []; - pending.push(linkIdx); - this.#pendingByEndpoint.set(endpointId, pending); - } - - takePending(endpointId: EntityId): LinkIdx[] | undefined { - const pending = this.#pendingByEndpoint.get(endpointId); - if (pending) { - this.#pendingByEndpoint.delete(endpointId); - } - return pending; - } - - resolveEndpoint( - linkIdx: LinkIdx, - side: "left" | "right", - entityIdx: EntityIdx, - ): void { - if (side === "left") { - this.#leftIdx.set(linkIdx, entityIdx); - } else { - this.#rightIdx.set(linkIdx, entityIdx); - } - this.#addToAdjacency(entityIdx, linkIdx); - } - - getLeft(linkIdx: number) { - return this.#leftIdx.get(linkIdx); - } - - getRight(linkIdx: number) { - return this.#rightIdx.get(linkIdx); - } - - getTypeSetIdx(linkIdx: number) { - return this.#typeIdx.get(linkIdx); - } - - /** The link's own entity index (a link is itself an entity). */ - getEntityIdx(linkIdx: number): EntityIdx { - return this.#entityIdIdx.get(linkIdx); - } - - /** - * Collect all links touching an entity, with direction info. - * O(degree) via the adjacency index. - */ - linksForEntity(entityIdx: EntityIdx): LinkEndpoint[] { - const linkIdxs = this.#adjacency.get(entityIdx); - if (!linkIdxs) { - return []; - } - - const result: LinkEndpoint[] = []; - for (const linkIdx of linkIdxs) { - const left = this.#leftIdx.get(linkIdx); - const right = this.#rightIdx.get(linkIdx); - - if (left === entityIdx && right !== -1) { - result.push({ - linkIdx, - otherIdx: right, - typeSetIdx: this.#typeIdx.get(linkIdx), - direction: "out", - }); - } else if (right === entityIdx && left !== -1) { - result.push({ - linkIdx, - otherIdx: left, - typeSetIdx: this.#typeIdx.get(linkIdx), - direction: "in", - }); - } - } - - return result; - } -} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts deleted file mode 100644 index 2cd1b1af1f8..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/stores/type-registry.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { extractBaseUrl } from "@blockprotocol/type-system"; - -import { BitSet } from "../collections/bitset"; -import { Interner } from "../collections/interner"; - -import type { TypeIdx } from "../../ids"; -import type { TypeSchemaEntry } from "../protocol"; -import type { VersionedUrl } from "@blockprotocol/type-system"; - -export interface TypeInfo { - readonly idx: TypeIdx; - readonly url: VersionedUrl; - readonly title: string; - /** For a link type, the inverse (target -> source) title. */ - readonly inverseTitle?: string; - readonly icon?: string; - readonly parentIdxs: readonly TypeIdx[]; - readonly ancestorClosure: BitSet; - readonly depth: number; - readonly rootIdxs: readonly TypeIdx[]; -} - -/** - * Owns the mapping from VersionedUrl to TypeIdx and the - * per-type metadata (title, icon, parent refs, ancestor closure). - */ -export class TypeRegistry { - readonly #interner: Interner = new Interner(); - readonly #types: (TypeInfo | undefined)[] = []; - /** - * Stable colour slot per type. Sorted by base URL within each batch and - * append-only, so a type's colour is deterministic across reloads. - */ - readonly #colorSlots: Map = new Map(); - #nextColorSlot = 0; - - get size(): number { - return this.#interner.size; - } - - intern(url: VersionedUrl): TypeIdx { - return this.#interner.intern(url); - } - - get(idx: TypeIdx): TypeInfo | undefined { - return this.#types[idx]; - } - - getUrl(idx: TypeIdx): VersionedUrl | undefined { - try { - return this.#interner.getValue(idx); - } catch { - return undefined; - } - } - - /** - * One line per registered type. An interned parent without a schema - * shows as `#(unreg)`. - */ - debugDump(): string { - const name = (idx: TypeIdx): string => - this.#types[idx]?.title ?? `#${idx}(unreg)`; - const lines: string[] = []; - for (const info of this.#types) { - if (!info) { - continue; - } - const parents = info.parentIdxs.map(name).join(", "); - const roots = info.rootIdxs.map(name).join(", "); - lines.push( - `#${info.idx} "${info.title}" parents=[${parents}] roots=[${roots}]`, - ); - } - return lines.join("\n"); - } - - /** - * Register all type schemas. Two passes: first intern everything - * (so parent refs resolve), then build ancestor closures. - */ - registerAll(schemas: readonly TypeSchemaEntry[]): void { - const newlyRegistered: TypeIdx[] = []; - - for (const schema of schemas) { - const idx = this.#interner.intern(schema.url); - - // Skip if this type already has TypeInfo registered. - if (this.#types[idx]) { - continue; - } - - newlyRegistered.push(idx); - const parentIdxs = schema.allOfRefs.map((ref) => - this.#interner.intern(ref), - ); - - this.#types[idx] = { - idx, - url: schema.url, - title: schema.title, - inverseTitle: schema.inverseTitle, - icon: schema.icon, - parentIdxs, - ancestorClosure: BitSet.empty(this.#interner.size), - depth: 0, - rootIdxs: [], - }; - } - - if (newlyRegistered.length > 0) { - this.#assignColorSlots(newlyRegistered); - this.#computeClosures(); - } - } - - #assignColorSlots(newlyRegistered: readonly TypeIdx[]): void { - const sorted = [...newlyRegistered].sort((left, right) => { - const leftUrl = extractBaseUrl(this.#types[left]!.url); - const rightUrl = extractBaseUrl(this.#types[right]!.url); - if (leftUrl < rightUrl) { - return -1; - } - return leftUrl > rightUrl ? 1 : 0; - }); - for (const idx of sorted) { - this.#colorSlots.set(idx, this.#nextColorSlot); - this.#nextColorSlot += 1; - } - } - - /** Stable colour slot for a type, or undefined if not yet registered. */ - colorSlot(idx: TypeIdx): number | undefined { - return this.#colorSlots.get(idx); - } - - #computeClosures(): void { - const universeSize = this.#interner.size; - - // Build closure bottom-up: each type's closure is itself + union of parent closures. - // Since the type hierarchy is a DAG, iterate until stable. - const closures = new Map>(); - const depths = new Map(); - const roots = new Map(); - - for (const info of this.#types) { - if (!info) { - continue; - } - - const closure = BitSet.fromBit(universeSize, info.idx); - closures.set(info.idx, closure); - depths.set(info.idx, 0); - roots.set(info.idx, []); - } - - // Fixed-point iteration (typically converges in 2 to 3 passes for shallow hierarchies). - let changed = true; - while (changed) { - changed = false; - - for (const info of this.#types) { - if (!info) { - continue; - } - - const current = closures.get(info.idx)!; - let merged = current; - - for (const parentIdx of info.parentIdxs) { - const parentClosure = closures.get(parentIdx); - if (parentClosure) { - const next = merged.or(parentClosure); - if (next.cardinality > merged.cardinality) { - merged = next; - changed = true; - } - } - } - - closures.set(info.idx, merged); - - // Depth: 1 + max parent depth. - if (info.parentIdxs.length > 0) { - const parentDepth = Math.max( - ...info.parentIdxs.map((parentIdx) => depths.get(parentIdx) ?? 0), - ); - const newDepth = parentDepth + 1; - if (newDepth > (depths.get(info.idx) ?? 0)) { - depths.set(info.idx, newDepth); - changed = true; - } - } - - // Roots: a parentless type is its own root; otherwise it inherits the - // union of its parents' roots. This must live inside the fixed-point. - // A single forward pass over idx order would read a parent's roots - // before they were computed whenever the child was interned first, - // and parents are interned lazily from `allOfRefs`, so child-before- - // parent is the common case. That zeroed the child's roots and dropped - // it into the catch-all "unknown" bucket (the Customer/Supplier bug). - if (info.parentIdxs.length === 0) { - if (roots.get(info.idx)!.length === 0) { - roots.set(info.idx, [info.idx]); - changed = true; - } - } else { - const rootSet = new Set(roots.get(info.idx)); - const before = rootSet.size; - for (const parentIdx of info.parentIdxs) { - for (const rootIdx of roots.get(parentIdx) ?? []) { - rootSet.add(rootIdx); - } - } - if (rootSet.size > before) { - roots.set(info.idx, [...rootSet]); - changed = true; - } - } - } - } - - // Write back. - for (let i = 0; i < this.#types.length; i++) { - const info = this.#types[i]; - if (!info) { - continue; - } - - this.#types[i] = { - ...info, - ancestorClosure: closures.get(info.idx) ?? info.ancestorClosure, - depth: depths.get(info.idx) ?? 0, - rootIdxs: roots.get(info.idx) ?? [], - }; - } - } -} diff --git a/yarn.lock b/yarn.lock index 9e2bfaa85e3..96e31220e9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -578,6 +578,11 @@ __metadata: "@blockprotocol/hook": "npm:0.1.8" "@blockprotocol/service": "npm:0.1.5" "@blockprotocol/type-system": "workspace:*" + "@deck.gl/core": "npm:9.3.4" + "@deck.gl/extensions": "npm:9.3.4" + "@deck.gl/layers": "npm:9.3.4" + "@deck.gl/react": "npm:9.3.4" + "@deck.gl/widgets": "npm:9.3.4" "@dnd-kit/core": "npm:6.3.1" "@dnd-kit/sortable": "npm:7.0.2" "@dnd-kit/utilities": "npm:3.2.2" @@ -608,6 +613,8 @@ __metadata: "@local/hash-isomorphic-utils": "workspace:*" "@local/status": "workspace:*" "@local/tsconfig": "workspace:*" + "@luma.gl/core": "npm:9.3.5" + "@luma.gl/engine": "npm:9.3.5" "@mantine/hooks": "npm:8.3.5" "@mui/icons-material": "npm:5.18.0" "@mui/material": "npm:5.18.0" @@ -630,6 +637,7 @@ __metadata: "@tldraw/primitives": "npm:2.0.0-alpha.12" "@tldraw/tldraw": "npm:2.0.0-alpha.12" "@tldraw/tlvalidate": "npm:2.0.0-alpha.12" + "@types/d3-force": "npm:3.0.10" "@types/dotenv-flow": "npm:3.3.3" "@types/gapi": "npm:0.0.47" "@types/google.accounts": "npm:0.0.18" @@ -651,6 +659,7 @@ __metadata: axios: "npm:1.16.1" buffer: "npm:6.0.3" clsx: "npm:2.1.1" + d3-force: "npm:3.0.0" date-fns: "npm:4.1.0" dompurify: "npm:3.4.11" dotenv-flow: "npm:3.3.0" @@ -661,6 +670,7 @@ __metadata: fractional-indexing: "npm:3.2.0" framer-motion: "npm:11.18.2" graphology: "npm:0.26.0" + graphology-communities-louvain: "npm:2.0.2" graphology-layout: "npm:0.6.1" graphology-layout-forceatlas2: "npm:0.10.1" graphology-shortest-path: "npm:2.1.0" @@ -716,9 +726,10 @@ __metadata: url-regex-safe: "npm:4.0.0" use-font-face-observer: "npm:1.3.0" uuid: "npm:14.0.0" - vitest: "npm:4.1.8" + vitest: "npm:4.1.9" wait-on: "npm:9.0.1" web-worker: "npm:1.4.1" + webcola: "npm:3.4.0" webpack: "npm:5.104.1" zod: "npm:4.4.3" languageName: unknown @@ -4505,6 +4516,91 @@ __metadata: languageName: node linkType: hard +"@deck.gl/core@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/core@npm:9.3.4" + dependencies: + "@loaders.gl/core": "npm:^4.4.1" + "@loaders.gl/images": "npm:^4.4.1" + "@luma.gl/core": "npm:^9.3.3" + "@luma.gl/engine": "npm:^9.3.3" + "@luma.gl/shadertools": "npm:^9.3.3" + "@luma.gl/webgl": "npm:^9.3.3" + "@math.gl/core": "npm:^4.1.0" + "@math.gl/sun": "npm:^4.1.0" + "@math.gl/types": "npm:^4.1.0" + "@math.gl/web-mercator": "npm:^4.1.0" + "@probe.gl/env": "npm:^4.1.1" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + "@types/offscreencanvas": "npm:^2019.6.4" + gl-matrix: "npm:^3.0.0" + mjolnir.js: "npm:^3.0.0" + checksum: 10c0/ecc4200d6ee6a7daef59c80c7e850dc8c042f11b7729d748987da3fce85d13e1a4bfef33d3bea0e53fa43e86b2216b595be9bd35fdec5171924fbb3f72d5312e + languageName: node + linkType: hard + +"@deck.gl/extensions@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/extensions@npm:9.3.4" + dependencies: + "@luma.gl/shadertools": "npm:^9.3.3" + "@luma.gl/webgl": "npm:^9.3.3" + "@math.gl/core": "npm:^4.1.0" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@luma.gl/core": ~9.3.3 + "@luma.gl/engine": ~9.3.3 + checksum: 10c0/6eadf761300e9062f435a67e6a4504578e37e5ccf636de40d152ff7c138c1ad52c81c1aa64eb5dff022082dda6116fb401bea83854baf5c3c8a376393ca7d498 + languageName: node + linkType: hard + +"@deck.gl/layers@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/layers@npm:9.3.4" + dependencies: + "@loaders.gl/images": "npm:^4.4.1" + "@loaders.gl/schema": "npm:^4.4.1" + "@luma.gl/shadertools": "npm:^9.3.3" + "@mapbox/tiny-sdf": "npm:^2.0.5" + "@math.gl/core": "npm:^4.1.0" + "@math.gl/polygon": "npm:^4.1.0" + "@math.gl/web-mercator": "npm:^4.1.0" + earcut: "npm:^2.2.4" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@loaders.gl/core": ^4.4.1 + "@luma.gl/core": ~9.3.3 + "@luma.gl/engine": ~9.3.3 + checksum: 10c0/e96cdb092ad77e9f1c1ca1db02a2e648129f2d7a2a0235c4779c3b1818b9735724cd64968866a1d507b60ed960382e8c2ce122aa781fbabc29616f3c8d26fd1b + languageName: node + linkType: hard + +"@deck.gl/react@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/react@npm:9.3.4" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@deck.gl/widgets": ~9.3.0 + react: ">=16.3.0" + react-dom: ">=16.3.0" + checksum: 10c0/b6abd804908680d46fa3c2109b277e311a434bda06a577ec25bf6ec02993e68405d48c776dd738317d6a81db7d582b593cb56187357517c5c063b5de7dfcc5e5 + languageName: node + linkType: hard + +"@deck.gl/widgets@npm:9.3.4": + version: 9.3.4 + resolution: "@deck.gl/widgets@npm:9.3.4" + dependencies: + "@floating-ui/dom": "npm:^1.7.5" + preact: "npm:^10.17.0" + peerDependencies: + "@deck.gl/core": ~9.3.0 + "@luma.gl/core": ~9.3.3 + checksum: 10c0/68669747a4ebb8c979a414ef5b47878b22065d4653ac4d72c20a2e336119210d2f589e43fe95e7b3662386442ffdfefe323b7a08f20aefd90c3f6ec18bf99e75 + languageName: node + linkType: hard + "@discoveryjs/json-ext@npm:0.5.7": version: 0.5.7 resolution: "@discoveryjs/json-ext@npm:0.5.7" @@ -5601,7 +5697,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.6": +"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.7.5, @floating-ui/dom@npm:^1.7.6": version: 1.7.6 resolution: "@floating-ui/dom@npm:1.7.6" dependencies: @@ -8096,6 +8192,74 @@ __metadata: languageName: node linkType: hard +"@loaders.gl/core@npm:^4.4.1": + version: 4.4.3 + resolution: "@loaders.gl/core@npm:4.4.3" + dependencies: + "@loaders.gl/loader-utils": "npm:4.4.3" + "@loaders.gl/schema": "npm:4.4.3" + "@loaders.gl/schema-utils": "npm:4.4.3" + "@loaders.gl/worker-utils": "npm:4.4.3" + "@probe.gl/log": "npm:^4.1.1" + checksum: 10c0/dcb412017844bc453918e2c2cf59910c5f1a2fec505cbc346db35a147dd562bde913604050a641f1891557c23ea936ad28102d3f1a3d01ba4146788ba805067b + languageName: node + linkType: hard + +"@loaders.gl/images@npm:^4.4.1": + version: 4.4.3 + resolution: "@loaders.gl/images@npm:4.4.3" + dependencies: + "@loaders.gl/loader-utils": "npm:4.4.3" + peerDependencies: + "@loaders.gl/core": ~4.4.0 + checksum: 10c0/d366127988a4a23f38354ed718ce3ccb817e7a3b5599758068ceeec7ae30d94614e31c05cc079761c2115137fb77c141cf09044e4270615ffc8492d762da96a8 + languageName: node + linkType: hard + +"@loaders.gl/loader-utils@npm:4.4.3": + version: 4.4.3 + resolution: "@loaders.gl/loader-utils@npm:4.4.3" + dependencies: + "@loaders.gl/schema": "npm:4.4.3" + "@loaders.gl/worker-utils": "npm:4.4.3" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + checksum: 10c0/82fd3a3d7b130d9d3794ec11f64cd6210e347732d732022b60f12f8560754043e1eae16126c689342436a84fdf7cfeeb828d8a9c76a1c3c84728f4bb304cc71a + languageName: node + linkType: hard + +"@loaders.gl/schema-utils@npm:4.4.3": + version: 4.4.3 + resolution: "@loaders.gl/schema-utils@npm:4.4.3" + dependencies: + "@loaders.gl/schema": "npm:4.4.3" + "@types/geojson": "npm:^7946.0.7" + apache-arrow: "npm:>= 17.0.0" + peerDependencies: + "@loaders.gl/core": ~4.4.0 + checksum: 10c0/bf03c6725ba85fba55bc74282d6660a914b7f33e9e69bbed5918a5793302b87e1c956dabe4e24a10f57c490aa1eb78441cbeb05f3f76ca88026c64162c120734 + languageName: node + linkType: hard + +"@loaders.gl/schema@npm:4.4.3, @loaders.gl/schema@npm:^4.4.1": + version: 4.4.3 + resolution: "@loaders.gl/schema@npm:4.4.3" + dependencies: + "@types/geojson": "npm:^7946.0.7" + apache-arrow: "npm:>= 17.0.0" + checksum: 10c0/59a9fd6047398a3eebb67fdd11fb2418feb91767955911573656d486718af06643e3b4e5ccd860612356031081f826197c876e61a3f0426fb20e6137a7498d4a + languageName: node + linkType: hard + +"@loaders.gl/worker-utils@npm:4.4.3": + version: 4.4.3 + resolution: "@loaders.gl/worker-utils@npm:4.4.3" + peerDependencies: + "@loaders.gl/core": ~4.4.0 + checksum: 10c0/b82093c822725c6a076dba9a133df5cbb31b646ec523ea3b2a5bda6443e0919d65a6b97e774482b3976621ea1b1d257139a6319acdfa6d5163f8408d0c78334a + languageName: node + linkType: hard + "@local/advanced-types@workspace:*, @local/advanced-types@workspace:libs/@local/advanced-types": version: 0.0.0-use.local resolution: "@local/advanced-types@workspace:libs/@local/advanced-types" @@ -8457,6 +8621,58 @@ __metadata: languageName: node linkType: hard +"@luma.gl/core@npm:9.3.5, @luma.gl/core@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/core@npm:9.3.5" + dependencies: + "@math.gl/types": "npm:^4.1.0" + "@probe.gl/env": "npm:^4.1.1" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + "@types/offscreencanvas": "npm:^2019.7.3" + checksum: 10c0/a43d764973463d9ee761358755dfaae85d12b5ffa955746bb6eb72fe285d1cb67af575dcdade7464005c8fbedec7d0b8af98284101cffea2d96ccbd8c7ec3bde + languageName: node + linkType: hard + +"@luma.gl/engine@npm:9.3.5, @luma.gl/engine@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/engine@npm:9.3.5" + dependencies: + "@math.gl/core": "npm:^4.1.0" + "@math.gl/types": "npm:^4.1.0" + "@probe.gl/log": "npm:^4.1.1" + "@probe.gl/stats": "npm:^4.1.1" + peerDependencies: + "@luma.gl/core": ~9.3.0 + "@luma.gl/shadertools": ~9.3.0 + checksum: 10c0/7e1404e079adbdd3c7b1787a22a2e1791bdf1f4796cbc230f8ab3e437a854f88d5bfb32792907d03bf05de3f3472f2324c300e3d99a10e5ded1e612fe21a4d53 + languageName: node + linkType: hard + +"@luma.gl/shadertools@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/shadertools@npm:9.3.5" + dependencies: + "@math.gl/core": "npm:^4.1.0" + "@math.gl/types": "npm:^4.1.0" + peerDependencies: + "@luma.gl/core": ~9.3.0 + checksum: 10c0/7e00b21b8c08d7862a2866ef08ca21ffd06560b4ba80ec231c7ab76182f5faa4c74dfc3f96ceef480a4ea205579c1ff882b3a95c77432a97dc2d3e7a54fe4ce0 + languageName: node + linkType: hard + +"@luma.gl/webgl@npm:^9.3.3": + version: 9.3.5 + resolution: "@luma.gl/webgl@npm:9.3.5" + dependencies: + "@math.gl/types": "npm:^4.1.0" + "@probe.gl/env": "npm:^4.1.1" + peerDependencies: + "@luma.gl/core": ~9.3.0 + checksum: 10c0/5a753f754098622191aabc1ae122e8f644d20a4d2068654b19dc2976c508cefaea73a7b1f0198ced726b836cb06e7ccb168897eaafcd8e4f5dba24e4e45ad89b + languageName: node + linkType: hard + "@mailchimp/mailchimp_marketing@npm:3.0.80": version: 3.0.80 resolution: "@mailchimp/mailchimp_marketing@npm:3.0.80" @@ -8502,6 +8718,13 @@ __metadata: languageName: node linkType: hard +"@mapbox/tiny-sdf@npm:^2.0.5": + version: 2.2.0 + resolution: "@mapbox/tiny-sdf@npm:2.2.0" + checksum: 10c0/9cfddf94bcdbedb047cd5e1bf17b8d90cdfe3e5d0286282769f697d8972955be33cc3e49c23f60d977c99439f3de80ed429b4bf73599cec441cb493ba3c6c8ed + languageName: node + linkType: hard + "@mark.probst/typescript-json-schema@npm:~0.32.0": version: 0.32.0 resolution: "@mark.probst/typescript-json-schema@npm:0.32.0" @@ -8516,6 +8739,47 @@ __metadata: languageName: node linkType: hard +"@math.gl/core@npm:4.1.0, @math.gl/core@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/core@npm:4.1.0" + dependencies: + "@math.gl/types": "npm:4.1.0" + checksum: 10c0/495934dc2be0b60cd6ff2cc16a0215608c9254919db741a0074b6b41cef9a0543c7f790eda7d529afa102d2937490608ef75fcc64c789ef2876ae750fd0ed3d6 + languageName: node + linkType: hard + +"@math.gl/polygon@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/polygon@npm:4.1.0" + dependencies: + "@math.gl/core": "npm:4.1.0" + checksum: 10c0/0fcfb489c5613ddff6dd0cbea65084e10fa9a3523fb87a36a4fdf10057d12d2a99f1ebd93da6e72b45db0783fb8cd9cff704765473372a8db580faaf50c85ab5 + languageName: node + linkType: hard + +"@math.gl/sun@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/sun@npm:4.1.0" + checksum: 10c0/baf813f3134124a1701c5f64cc8725fdb1d8d4c9e47c7fbd0d2e960d823f776d35312f82c62a09dcb140ea31d42b3945aa781066495291df190566ae17b5857b + languageName: node + linkType: hard + +"@math.gl/types@npm:4.1.0, @math.gl/types@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/types@npm:4.1.0" + checksum: 10c0/3c4dfa5ac5c9e2cef24d31f56b89c1dde785a5d70fd1a7030386346cb7dd4fa2cce5ba983b89842c1971492e30870dd22a078d64893f9c66887e38367bf992fa + languageName: node + linkType: hard + +"@math.gl/web-mercator@npm:^4.1.0": + version: 4.1.0 + resolution: "@math.gl/web-mercator@npm:4.1.0" + dependencies: + "@math.gl/core": "npm:4.1.0" + checksum: 10c0/7aa4921b9442da75664ef517f41de65b1eae9970d7eee61d14d2eb0b332e1af6203785e8d198376a8ab5924d62b24856d648ff6478cca95b14d3a8d82822ef93 + languageName: node + linkType: hard + "@mdx-js/mdx@npm:^3.1.0": version: 3.1.1 resolution: "@mdx-js/mdx@npm:3.1.1" @@ -10791,6 +11055,29 @@ __metadata: languageName: node linkType: hard +"@probe.gl/env@npm:4.1.1, @probe.gl/env@npm:^4.1.1": + version: 4.1.1 + resolution: "@probe.gl/env@npm:4.1.1" + checksum: 10c0/431f53d95936ba1ee08e8d4c155cc3c49d80d528acaff933ae8f32a4725f04d88ee634707e20baa885a505d6f1401f895bdfcfa42c259b3220dd26463d2cfb2d + languageName: node + linkType: hard + +"@probe.gl/log@npm:^4.1.1": + version: 4.1.1 + resolution: "@probe.gl/log@npm:4.1.1" + dependencies: + "@probe.gl/env": "npm:4.1.1" + checksum: 10c0/713043aea095dbe6e97525a5d254f2563e9e0b6c64fdb888603f41342ed0f0a76a64e6996d2d461c7f1f9c6271a931b2e922d9e47f8c43f3d4116867f4cd5d0a + languageName: node + linkType: hard + +"@probe.gl/stats@npm:^4.1.1": + version: 4.1.1 + resolution: "@probe.gl/stats@npm:4.1.1" + checksum: 10c0/7fd49d891e5ecfc82330c0db9180cc6a3fae0f78062570fabdc1860f79f09b612c53af08b9d6e2bb701b79fb7156ec2c9c0ddf53e5dba12b0560c7bbc957afc7 + languageName: node + linkType: hard + "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -15236,6 +15523,15 @@ __metadata: languageName: node linkType: hard +"@swc/helpers@npm:^0.5.11": + version: 0.5.23 + resolution: "@swc/helpers@npm:0.5.23" + dependencies: + tslib: "npm:^2.8.0" + checksum: 10c0/02da7b4df465693933ecd4851cc193ec729c309939c8a84eccae5ec0010aafc3894e713b8ef8d13a6ba401759f0e900c88e2dcfef5872c27bb91e70f73275cce + languageName: node + linkType: hard + "@swc/types@npm:^0.1.25": version: 0.1.25 resolution: "@swc/types@npm:0.1.25" @@ -15999,6 +16295,20 @@ __metadata: languageName: node linkType: hard +"@types/command-line-args@npm:^5.2.3": + version: 5.2.3 + resolution: "@types/command-line-args@npm:5.2.3" + checksum: 10c0/3a9bc58fd26e546391f6369dd28c03d59349dc4ac39eada1a5c39cc3578e02e4aac222615170e0db79b198ffba2af84fdbdda46e08c6edc4da42bc17ea85200f + languageName: node + linkType: hard + +"@types/command-line-usage@npm:^5.0.4": + version: 5.0.4 + resolution: "@types/command-line-usage@npm:5.0.4" + checksum: 10c0/67840ebf4bcfee200c07d978669ad596fe2adc350fd5c19d44ec2248623575d96ec917f513d1d59453f8f57e879133861a4cc41c20045c07f6c959f1fcaac7ad + languageName: node + linkType: hard + "@types/connect-history-api-fallback@npm:^1.5.4": version: 1.5.4 resolution: "@types/connect-history-api-fallback@npm:1.5.4" @@ -16122,7 +16432,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-force@npm:*": +"@types/d3-force@npm:*, @types/d3-force@npm:3.0.10": version: 3.0.10 resolution: "@types/d3-force@npm:3.0.10" checksum: 10c0/c82b459079a106b50e346c9b79b141f599f2fc4f598985a5211e72c7a2e20d35bd5dc6e91f306b323c8bfa325c02c629b1645f5243f1c6a55bd51bc85cccfa92 @@ -16459,6 +16769,13 @@ __metadata: languageName: node linkType: hard +"@types/geojson@npm:^7946.0.7": + version: 7946.0.16 + resolution: "@types/geojson@npm:7946.0.16" + checksum: 10c0/1ff24a288bd5860b766b073ead337d31d73bdc715e5b50a2cee5cb0af57a1ed02cc04ef295f5fa68dc40fe3e4f104dd31282b2b818a5ba3231bc1001ba084e3c + languageName: node + linkType: hard + "@types/glob@npm:^7.1.1, @types/glob@npm:^7.1.3": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" @@ -16810,6 +17127,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^24.0.3": + version: 24.13.2 + resolution: "@types/node@npm:24.13.2" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/d7d48a88a4feb0a6aac3cbfaf9ef3b12752b4b09447f88dd0b4c77c03b281e3d4330fe6982a99aedcd63fc16c7540a0c248b91eb2abb0b3edd884d7fe684e9ea + languageName: node + linkType: hard + "@types/nodemailer@npm:6.4.17": version: 6.4.17 resolution: "@types/nodemailer@npm:6.4.17" @@ -16833,6 +17159,13 @@ __metadata: languageName: node linkType: hard +"@types/offscreencanvas@npm:^2019.6.4, @types/offscreencanvas@npm:^2019.7.3": + version: 2019.7.3 + resolution: "@types/offscreencanvas@npm:2019.7.3" + checksum: 10c0/6d1dfae721d321cd2b5435f347a0e53b09f33b2f9e9333396480f592823bc323847b8169f7d251d2285cb93dbc1ba2e30741ac5cf4b1c003d660fd4c24526963 + languageName: node + linkType: hard + "@types/papaparse@npm:5.3.16": version: 5.3.16 resolution: "@types/papaparse@npm:5.3.16" @@ -18032,6 +18365,20 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 + languageName: node + linkType: hard + "@vitest/mocker@npm:3.2.4": version: 3.2.4 resolution: "@vitest/mocker@npm:3.2.4" @@ -18070,6 +18417,25 @@ __metadata: languageName: node linkType: hard +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" + dependencies: + "@vitest/spy": "npm:4.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 + languageName: node + linkType: hard + "@vitest/pretty-format@npm:3.2.4": version: 3.2.4 resolution: "@vitest/pretty-format@npm:3.2.4" @@ -18088,6 +18454,15 @@ __metadata: languageName: node linkType: hard +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de + languageName: node + linkType: hard + "@vitest/runner@npm:4.1.8": version: 4.1.8 resolution: "@vitest/runner@npm:4.1.8" @@ -18098,6 +18473,16 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" + dependencies: + "@vitest/utils": "npm:4.1.9" + pathe: "npm:^2.0.3" + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb + languageName: node + linkType: hard + "@vitest/snapshot@npm:4.1.8": version: 4.1.8 resolution: "@vitest/snapshot@npm:4.1.8" @@ -18110,6 +18495,18 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 + languageName: node + linkType: hard + "@vitest/spy@npm:3.2.4": version: 3.2.4 resolution: "@vitest/spy@npm:3.2.4" @@ -18126,6 +18523,13 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 + languageName: node + linkType: hard + "@vitest/utils@npm:3.2.4": version: 3.2.4 resolution: "@vitest/utils@npm:3.2.4" @@ -18148,6 +18552,17 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 + languageName: node + linkType: hard + "@volar/language-core@npm:2.4.23, @volar/language-core@npm:~2.4.11": version: 2.4.23 resolution: "@volar/language-core@npm:2.4.23" @@ -19962,6 +20377,25 @@ __metadata: languageName: node linkType: hard +"apache-arrow@npm:>= 17.0.0": + version: 21.1.0 + resolution: "apache-arrow@npm:21.1.0" + dependencies: + "@swc/helpers": "npm:^0.5.11" + "@types/command-line-args": "npm:^5.2.3" + "@types/command-line-usage": "npm:^5.0.4" + "@types/node": "npm:^24.0.3" + command-line-args: "npm:^6.0.1" + command-line-usage: "npm:^7.0.1" + flatbuffers: "npm:^25.1.24" + json-bignum: "npm:^0.0.3" + tslib: "npm:^2.6.2" + bin: + arrow2csv: bin/arrow2csv.js + checksum: 10c0/d689b433d0a07bf8741870bd9ae363a64efe73394f6b0a59eb0732eb33c388d657273a4e2a7f89827add7ad7dcb3680b0a9a34f6f1818b370b3b22b43e06d6e1 + languageName: node + linkType: hard + "app-root-path@npm:*, app-root-path@npm:3.1.0": version: 3.1.0 resolution: "app-root-path@npm:3.1.0" @@ -20044,6 +20478,13 @@ __metadata: languageName: node linkType: hard +"array-back@npm:^6.2.2, array-back@npm:^6.2.3": + version: 6.2.3 + resolution: "array-back@npm:6.2.3" + checksum: 10c0/921bd7857112b8fbe3bc72f3498ca7c1c1652406e966a2468dcb8f67672c43e8b58e734138141c22fc21c357e59eaccc44b7727738a666f1056fc1f646b335df + languageName: node + linkType: hard + "array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" @@ -21315,6 +21756,15 @@ __metadata: languageName: node linkType: hard +"chalk-template@npm:^0.4.0": + version: 0.4.0 + resolution: "chalk-template@npm:0.4.0" + dependencies: + chalk: "npm:^4.1.2" + checksum: 10c0/6a4cb4252966475f0bd3ee1cd8780146e1ba69f445e59c565cab891ac18708c8143515d23e2b0fb7e192574fb7608d429ea5b28f3b7b9507770ad6fccd3467e3 + languageName: node + linkType: hard + "chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -21910,6 +22360,23 @@ __metadata: languageName: node linkType: hard +"command-line-args@npm:^6.0.1": + version: 6.0.2 + resolution: "command-line-args@npm:6.0.2" + dependencies: + array-back: "npm:^6.2.3" + find-replace: "npm:^5.0.2" + lodash.camelcase: "npm:^4.3.0" + typical: "npm:^7.3.0" + peerDependencies: + "@75lb/nature": "*" + peerDependenciesMeta: + "@75lb/nature": + optional: true + checksum: 10c0/4d66a1c4ae86bd86d9934ae85410bdedbe8796a060ad4e7cf7b9ec8275044f114b30965436a2c9a91f86d7a4279fb1ae825019f5861ddf048f275c6a0f24aa02 + languageName: node + linkType: hard + "command-line-usage@npm:^5.0.5": version: 5.0.5 resolution: "command-line-usage@npm:5.0.5" @@ -21922,6 +22389,18 @@ __metadata: languageName: node linkType: hard +"command-line-usage@npm:^7.0.1": + version: 7.0.4 + resolution: "command-line-usage@npm:7.0.4" + dependencies: + array-back: "npm:^6.2.2" + chalk-template: "npm:^0.4.0" + table-layout: "npm:^4.1.1" + typical: "npm:^7.3.0" + checksum: 10c0/0ca2118342f0a587b8509ddb47436e2ce566fddbfbdf6ceb470b65f3da2a4f3af03fd4d9768a867410b2741cb1b0bc6fdab7f33306c09af92d699dc1a87b6818 + languageName: node + linkType: hard + "commander@npm:8.3.0, commander@npm:^8.3.0": version: 8.3.0 resolution: "commander@npm:8.3.0" @@ -22569,6 +23048,13 @@ __metadata: languageName: node linkType: hard +"d3-dispatch@npm:1, d3-dispatch@npm:^1.0.3": + version: 1.0.6 + resolution: "d3-dispatch@npm:1.0.6" + checksum: 10c0/6302554a019e2d75d4e3dc7e8757a00b4b12ac2a2952bccc66e4478ccd170f425e2b6a9443118d5feadcd2439f33582b63c7925e832104ff1978cadea2a30dc2 + languageName: node + linkType: hard + "d3-drag@npm:2 - 3, d3-drag@npm:^3.0.0": version: 3.0.0 resolution: "d3-drag@npm:3.0.0" @@ -22579,6 +23065,16 @@ __metadata: languageName: node linkType: hard +"d3-drag@npm:^1.0.4": + version: 1.2.5 + resolution: "d3-drag@npm:1.2.5" + dependencies: + d3-dispatch: "npm:1" + d3-selection: "npm:1" + checksum: 10c0/749cbeaab1867a10086c90467218365ab1967645878463074a63383d9de77fe8b6a7ebe6c5be8fd4e257575db87c9b99a2d76b4d3ebb8b937f3523414fbc9c2b + languageName: node + linkType: hard + "d3-ease@npm:1 - 3, d3-ease@npm:^3.0.1": version: 3.0.1 resolution: "d3-ease@npm:3.0.1" @@ -22586,6 +23082,17 @@ __metadata: languageName: node linkType: hard +"d3-force@npm:3.0.0": + version: 3.0.0 + resolution: "d3-force@npm:3.0.0" + dependencies: + d3-dispatch: "npm:1 - 3" + d3-quadtree: "npm:1 - 3" + d3-timer: "npm:1 - 3" + checksum: 10c0/220a16a1a1ac62ba56df61028896e4b52be89c81040d20229c876efc8852191482c233f8a52bb5a4e0875c321b8e5cb6413ef3dfa4d8fe79eeb7d52c587f52cf + languageName: node + linkType: hard + "d3-format@npm:1 - 3": version: 3.1.2 resolution: "d3-format@npm:3.1.2" @@ -22602,6 +23109,13 @@ __metadata: languageName: node linkType: hard +"d3-path@npm:1": + version: 1.0.9 + resolution: "d3-path@npm:1.0.9" + checksum: 10c0/e35e84df5abc18091f585725b8235e1fa97efc287571585427d3a3597301e6c506dea56b11dfb3c06ca5858b3eb7f02c1bf4f6a716aa9eade01c41b92d497eb5 + languageName: node + linkType: hard + "d3-path@npm:^3.1.0": version: 3.1.0 resolution: "d3-path@npm:3.1.0" @@ -22609,6 +23123,13 @@ __metadata: languageName: node linkType: hard +"d3-quadtree@npm:1 - 3": + version: 3.0.1 + resolution: "d3-quadtree@npm:3.0.1" + checksum: 10c0/18302d2548bfecaef788152397edec95a76400fd97d9d7f42a089ceb68d910f685c96579d74e3712d57477ed042b056881b47cd836a521de683c66f47ce89090 + languageName: node + linkType: hard + "d3-scale@npm:^4.0.2": version: 4.0.2 resolution: "d3-scale@npm:4.0.2" @@ -22622,6 +23143,13 @@ __metadata: languageName: node linkType: hard +"d3-selection@npm:1": + version: 1.4.2 + resolution: "d3-selection@npm:1.4.2" + checksum: 10c0/e755b6b62d794d0b968cc6264f37109e425de0d9fd306ce94414b07e46a2c6830d21c1fe0821a660d07e82069b6fe3b67da1b3b909e8f6af8f16f020cd25cae0 + languageName: node + linkType: hard + "d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0": version: 3.0.0 resolution: "d3-selection@npm:3.0.0" @@ -22629,6 +23157,15 @@ __metadata: languageName: node linkType: hard +"d3-shape@npm:^1.3.5": + version: 1.3.7 + resolution: "d3-shape@npm:1.3.7" + dependencies: + d3-path: "npm:1" + checksum: 10c0/548057ce59959815decb449f15632b08e2a1bdce208f9a37b5f98ec7629dda986c2356bc7582308405ce68aedae7d47b324df41507404df42afaf352907577ae + languageName: node + linkType: hard + "d3-shape@npm:^3.1.0": version: 3.2.0 resolution: "d3-shape@npm:3.2.0" @@ -22663,6 +23200,13 @@ __metadata: languageName: node linkType: hard +"d3-timer@npm:^1.0.5": + version: 1.0.10 + resolution: "d3-timer@npm:1.0.10" + checksum: 10c0/7e77030a206861e4e626754c689795d43f036fb07a7f8ca6360eb8b7cbe6f52bf43c9c4297ae9a9a906e4de594212702f83c0cde23d4e20d8689a4211e438155 + languageName: node + linkType: hard + "d3-transition@npm:2 - 3": version: 3.0.1 resolution: "d3-transition@npm:3.0.1" @@ -23510,6 +24054,13 @@ __metadata: languageName: node linkType: hard +"earcut@npm:^2.2.4": + version: 2.2.4 + resolution: "earcut@npm:2.2.4" + checksum: 10c0/01ca51830edd2787819f904ae580087d37351f6048b4565e7add4b3da8a86b7bc19262ab2aa7fdc64129ab03af2d9cec8cccee4d230c82275f97ef285c79aafb + languageName: node + linkType: hard + "easy-table@npm:1.1.0": version: 1.1.0 resolution: "easy-table@npm:1.1.0" @@ -25969,6 +26520,18 @@ __metadata: languageName: node linkType: hard +"find-replace@npm:^5.0.2": + version: 5.0.2 + resolution: "find-replace@npm:5.0.2" + peerDependencies: + "@75lb/nature": "*" + peerDependenciesMeta: + "@75lb/nature": + optional: true + checksum: 10c0/25db7167e8767b0683251a985af82e29b0ac26003fe2cd6ddd86e4f2c83fc10d97a81c6f6686034d8c2b9ee88bc53547bf86cc103479a7323342da5ace9f6504 + languageName: node + linkType: hard + "find-root@npm:^1.1.0": version: 1.1.0 resolution: "find-root@npm:1.1.0" @@ -26053,6 +26616,13 @@ __metadata: languageName: node linkType: hard +"flatbuffers@npm:^25.1.24": + version: 25.9.23 + resolution: "flatbuffers@npm:25.9.23" + checksum: 10c0/957c4ae2a02be1703c98b36b4dc8ceb81613cf8e2333026afc95a7b68b088bed5458056dc29d0ab7ce8bc403b8c003732b0968d24aba46f5e7c8f71789a6bd9e + languageName: node + linkType: hard + "flatted@npm:^3.2.9": version: 3.4.2 resolution: "flatted@npm:3.4.2" @@ -26615,6 +27185,13 @@ __metadata: languageName: node linkType: hard +"gl-matrix@npm:^3.0.0": + version: 3.4.4 + resolution: "gl-matrix@npm:3.4.4" + checksum: 10c0/9aa022ffac0d158212ad0cd29939864ad919ac31cd5dc5a5d35e9d66bb62679ddf152ff7b2173ded20131045e40572b87f31b26a920be2a7583a1516b13b5b4b + languageName: node + linkType: hard + "glob-parent@npm:6.0.2, glob-parent@npm:^6.0.1, glob-parent@npm:^6.0.2": version: 6.0.2 resolution: "glob-parent@npm:6.0.2" @@ -26941,6 +27518,20 @@ __metadata: languageName: node linkType: hard +"graphology-communities-louvain@npm:2.0.2": + version: 2.0.2 + resolution: "graphology-communities-louvain@npm:2.0.2" + dependencies: + graphology-indices: "npm:^0.17.0" + graphology-utils: "npm:^2.4.4" + mnemonist: "npm:^0.39.0" + pandemonium: "npm:^2.4.1" + peerDependencies: + graphology-types: ">=0.19.0" + checksum: 10c0/efc2007dfcc8ace26b917fbc50feda1914143ae77476498570e68cc1d831a4ee83cc74c9311eec84488eaa6ce5e245b5a84fe5fdb072764edcd64894a9d9ecfe + languageName: node + linkType: hard + "graphology-indices@npm:^0.17.0": version: 0.17.0 resolution: "graphology-indices@npm:0.17.0" @@ -27018,7 +27609,7 @@ __metadata: languageName: node linkType: hard -"graphology-utils@npm:^2.1.0, graphology-utils@npm:^2.3.0, graphology-utils@npm:^2.4.2, graphology-utils@npm:^2.4.3, graphology-utils@npm:^2.5.2": +"graphology-utils@npm:^2.1.0, graphology-utils@npm:^2.3.0, graphology-utils@npm:^2.4.2, graphology-utils@npm:^2.4.3, graphology-utils@npm:^2.4.4, graphology-utils@npm:^2.5.2": version: 2.5.2 resolution: "graphology-utils@npm:2.5.2" peerDependencies: @@ -29717,6 +30308,13 @@ __metadata: languageName: node linkType: hard +"json-bignum@npm:^0.0.3": + version: 0.0.3 + resolution: "json-bignum@npm:0.0.3" + checksum: 10c0/f9f9312d57a68f72676802fa087da4ed60241d73b6cc0e3fb9f587ca0de7364efb62612a14414ccfbedc0b77ce3c320adca21834a5673c99eb3375aef9f561db + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -32347,6 +32945,13 @@ __metadata: languageName: node linkType: hard +"mjolnir.js@npm:^3.0.0": + version: 3.0.0 + resolution: "mjolnir.js@npm:3.0.0" + checksum: 10c0/bdbe0b6ab1420917a68fa0faa47351295b189816ad67c9ace36c24ff341f873a7e4d6ea704e33f2a6f9b8fc0ff6b8d17e30ad74aed455da38d97ba52198a6274 + languageName: node + linkType: hard + "mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3": version: 0.5.3 resolution: "mkdirp-classic@npm:0.5.3" @@ -34150,7 +34755,7 @@ __metadata: languageName: node linkType: hard -"pandemonium@npm:^2.4.0": +"pandemonium@npm:^2.4.0, pandemonium@npm:^2.4.1": version: 2.4.1 resolution: "pandemonium@npm:2.4.1" dependencies: @@ -35037,6 +35642,13 @@ __metadata: languageName: node linkType: hard +"preact@npm:^10.17.0": + version: 10.29.3 + resolution: "preact@npm:10.29.3" + checksum: 10c0/deede846649c6f31888d36b60de2d2433a85c533268b328af82da11a5c1d9b6f12439bdab6dca3cf7a20720b4137c2f1644c12db21b30b18360766ab70c8fe06 + languageName: node + linkType: hard + "prebuild-install@npm:^7.1.3": version: 7.1.3 resolution: "prebuild-install@npm:7.1.3" @@ -39428,6 +40040,16 @@ __metadata: languageName: node linkType: hard +"table-layout@npm:^4.1.1": + version: 4.1.1 + resolution: "table-layout@npm:4.1.1" + dependencies: + array-back: "npm:^6.2.2" + wordwrapjs: "npm:^5.1.0" + checksum: 10c0/26d8e54a55ddb4de447c8f02a8d7fcbb66a9580375e406a3bc7717ab223a413f6dfbded6710f288b3dfd277991813a0bd5a17419a0dc6db54d9a36d883d868dc + languageName: node + linkType: hard + "table@npm:6.9.0": version: 6.9.0 resolution: "table@npm:6.9.0" @@ -40612,6 +41234,13 @@ __metadata: languageName: node linkType: hard +"typical@npm:^7.3.0": + version: 7.3.0 + resolution: "typical@npm:7.3.0" + checksum: 10c0/bee697a88e1dd0447bc1cf7f6e875eaa2b0fb2cccb338b7b261e315f7df84a66402864bfc326d6b3117c50475afd1d49eda03d846a6299ad25f211035bfab3b1 + languageName: node + linkType: hard + "ufo@npm:^1.6.1": version: 1.6.1 resolution: "ufo@npm:1.6.1" @@ -40722,6 +41351,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + "undici@npm:^7.10.0": version: 7.28.0 resolution: "undici@npm:7.28.0" @@ -41682,6 +42318,74 @@ __metadata: languageName: node linkType: hard +"vitest@npm:4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" + dependencies: + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + languageName: node + linkType: hard + "vscode-languageserver-types@npm:3.17.5": version: 3.17.5 resolution: "vscode-languageserver-types@npm:3.17.5" @@ -41801,6 +42505,18 @@ __metadata: languageName: node linkType: hard +"webcola@npm:3.4.0": + version: 3.4.0 + resolution: "webcola@npm:3.4.0" + dependencies: + d3-dispatch: "npm:^1.0.3" + d3-drag: "npm:^1.0.4" + d3-shape: "npm:^1.3.5" + d3-timer: "npm:^1.0.5" + checksum: 10c0/509ebbe30f69465ad0df1baf041cebf9ddab999b1edc3c30ab4fbb1a3b0a0cdbb5a759aeb55e42c3a01da3758d3db4b19bd43368c3b1fb959526beabf03a0c6e + languageName: node + linkType: hard + "webextension-polyfill@npm:0.12.0": version: 0.12.0 resolution: "webextension-polyfill@npm:0.12.0" @@ -42299,7 +43015,7 @@ __metadata: languageName: node linkType: hard -"wordwrapjs@npm:5.1.1": +"wordwrapjs@npm:5.1.1, wordwrapjs@npm:^5.1.0": version: 5.1.1 resolution: "wordwrapjs@npm:5.1.1" checksum: 10c0/8e2000ab679e583c119b92f1740b7e55d9eccae0588a449737a974920445c579db14ddcb36efb0e7a52d4c9877adad9e2ea2a416af69626a9a5d22ba06efb62a From fdb29887785a43cdd2a58e2a4f8b1bd2bd56be7e Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:27:30 +0200 Subject: [PATCH 43/78] feat: code review --- .../generate-fixture/build-entities.ts | 2 +- .../{worker => math}/random.test.ts | 0 .../{worker => math}/random.ts | 24 + .../worker/bench-fixtures.ts | 2 +- .../worker/buffers/entity-id-buffer.test.ts | 1 - .../worker/buffers/entity-id-buffer.ts | 17 +- .../worker/buffers/growable-buffer.ts | 73 +- .../worker/buffers/position-buffer.test.ts | 1 - .../worker/buffers/position-buffer.ts | 68 +- .../worker/collections/bitset.ts | 2 - .../worker/collections/column.ts | 54 +- .../worker/collections/interner.ts | 7 +- .../collections/readonly-sorted-set.test.ts | 53 + .../worker/collections/readonly-sorted-set.ts | 10 +- .../worker/core/graph-worker.ts | 607 ++-- .../worker/core/layout-reuse.ts | 35 +- .../worker/core/viewport-anchor.ts | 28 +- .../worker/geometry/bubble-ports.ts | 92 +- .../worker/geometry/edge-aggregation.ts | 299 +- .../worker/geometry/edge-geometry.ts | 14 - .../worker/geometry/world-positions.ts | 25 +- .../hierarchy/cluster-feature-source.ts | 18 +- .../worker/hierarchy/cluster-tree.ts | 198 +- .../worker/hierarchy/community.ts | 67 +- .../hierarchy/distinctive-cluster-label.ts | 136 +- .../worker/hierarchy/lod.ts | 78 +- .../layout/community-layout-cost.bench.ts | 2 +- .../worker/layout/community-layout.test.ts | 1 - .../worker/layout/community-layout.ts | 2 +- .../worker/layout/overlap-relax.test.ts | 113 + .../worker/layout/overlap-relax.ts | 257 ++ .../layout/sparse-stress-solver.test.ts | 218 ++ .../worker/layout/sparse-stress-solver.ts | 3024 +++++++++++++++++ .../worker/layout/stress-layout.test.ts | 240 ++ .../worker/layout/stress-layout.ts | 466 +++ .../worker/layout/stress-vs-fa2.bench.ts | 273 ++ .../worker/layout/top-level-layout.ts | 2 +- .../worker/layout/untangle.ts | 2 +- .../graph-visualizer-2/worker/store/link.ts | 21 +- 39 files changed, 5281 insertions(+), 1251 deletions(-) rename apps/hash-frontend/src/pages/shared/graph-visualizer-2/{worker => math}/random.test.ts (100%) rename apps/hash-frontend/src/pages/shared/graph-visualizer-2/{worker => math}/random.ts (60%) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts index aa4848f6eb2..1b08db5c600 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/dev-harness/generate-fixture/build-entities.ts @@ -10,7 +10,7 @@ */ import { HashEntity } from "@local/hash-graph-sdk/entity"; -import { mulberry32 } from "../../worker/random"; +import { mulberry32 } from "../../math/random"; import type { FixtureEntityKind } from "./build-type-maps"; import type { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/random.test.ts similarity index 100% rename from apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.test.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/random.test.ts diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/random.ts similarity index 60% rename from apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts rename to apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/random.ts index 2c3255184a4..c6ea0c3cada 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/random.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/math/random.ts @@ -34,3 +34,27 @@ export function parkMillerRng(seed: number): () => number { return state / 2147483647; }; } + +/** + * Fisher-Yates shuffle driven by an xorshift PRNG. + * Returns a new array; the input is not mutated. + */ +export function deterministicShuffle( + indices: number[], + seed: number, +): number[] { + const result = [...indices]; + let state = seed * 0x9e3779b9; + + for (let idx = result.length - 1; idx > 0; idx--) { + state = (state ^ (state << 13)) | 0; + state = (state ^ (state >>> 17)) | 0; + state = (state ^ (state << 5)) | 0; + const target = (state >>> 0) % (idx + 1); + const temp = result[idx]!; + result[idx] = result[target]!; + result[target] = temp; + } + + return result; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts index bc2830b48bd..951f8c026b6 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts @@ -2,9 +2,9 @@ import { entityIdFromComponents } from "@blockprotocol/type-system"; import { EntityIndex, TypeSetId } from "../ids"; +import { mulberry32 } from "../math/random"; import { Column } from "./collections/column"; import { radiusForDegree } from "./entity-style"; -import { mulberry32 } from "./random"; import { LinkStore } from "./store/link"; import type { ForceEdge, ForceNode } from "./layout/force-simulation"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts index 5dffc74b14e..dcd6481070b 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.test.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; import { entityIdFromComponents } from "@blockprotocol/type-system"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts index dcd87b31ed2..e66ef907115 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/entity-id-buffer.ts @@ -1,12 +1,7 @@ /** - * SharedArrayBuffer-backed `EntityIdx -> EntityId` map: the join key that lets the - * main thread turn a rendered record back into its entity (for labels, icons, tooltips, - * picking) without shuffling per-entity data through the worker. - * - * The worker is the sole writer (it owns interning); the main thread is a reader. It is - * synchronized by the same atomic version bump as the position buffers, with no message, - * no library, no bidirectional sync. The byte layout lives in {@link "../entity-id-codec"} - * so the main-thread reader shares one decoder; this class is just the growable store. + * SharedArrayBuffer-backed `EntityIdx -> EntityId` map. The worker writes; + * the main thread reads via atomic version sync. Byte layout is defined + * in {@link "../entity-id-codec"}. */ import { ENTITY_ID_BYTES, @@ -18,8 +13,7 @@ import { GrowableBuffer, type RepublishHandler } from "./growable-buffer"; import type { EntityId } from "@blockprotocol/type-system"; -/** Default growable ceiling (entities): reserves address space, commits as it grows. - * A re-publish only fires past this. */ +/** Default growable ceiling (entities). Reserves address space, not committed memory. */ const ENTITY_ID_MAX_CAPACITY = 262_144; export class EntityIdBuffer extends GrowableBuffer { @@ -45,8 +39,7 @@ export class EntityIdBuffer extends GrowableBuffer { encodeEntityId(this.#bytes, entityIdx, entityId); } - /** Reconstruct the EntityId for an EntityIdx (exercised by the round-trip tests; the - * main thread decodes its received buffer with {@link decodeEntityId} directly). */ + /** Reconstruct the EntityId for an EntityIdx. */ readId(entityIdx: number): EntityId { return decodeEntityId(this.#bytes, entityIdx); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts index 146e3d7ac3f..18bdbea8510 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/growable-buffer.ts @@ -1,41 +1,25 @@ /* eslint-disable no-bitwise */ /** - * Growable SharedArrayBuffer primitives + the {@link GrowableBuffer} base shared by - * every SharedArrayBuffer-backed store that streams and grows ({@link FlatGraphBuffer} in - * position-buffer.ts, the EntityId map in entity-id-buffer.ts). + * Growable SharedArrayBuffer primitives. * - * What those stores have in common is not their record layout, it's the grow-or- - * republish dance: try to extend the buffer in place (ES2024 growable SharedArrayBuffer) - * and, when that's impossible (growable shared buffers unsupported, or the `maxByteLength` - * ceiling hit), re-allocate a larger buffer, copy the bytes across, re-bind the - * typed-array views, and tell the main thread to swap to the new buffer. Hand-rolling that - * per store is how the subtle bits (which views are length-tracking, when a copy is - * needed, when a message must fire) drift apart. {@link GrowableBuffer} owns it exactly - * once; a subclass only describes its record layout and re-binds its own views. + * {@link GrowableBuffer} owns the grow-or-republish dance: grow in place + * when possible (ES2024 growable SharedArrayBuffer), otherwise re-allocate, + * copy, re-bind views, and notify the main thread. Subclasses describe + * their record layout and re-bind views via {@link GrowableBuffer.bindRecordViews}. */ /** SharedArrayBuffer is available at all (cross-origin isolation present). */ export const sharedBufferAvailable = typeof SharedArrayBuffer !== "undefined"; -/** - * Whether SharedArrayBuffers can grow in place (ES2024 growable SharedArrayBuffer). Where - * present, a buffer grows with no re-publish up to its `maxByteLength` and the main thread - * just re-reads the same (now-larger) buffer. Where absent, {@link growBuffer} declines, so - * {@link GrowableBuffer} re-allocates a bigger buffer and re-publishes it, the universal - * fallback. - */ +/** Whether SharedArrayBuffers can grow in place (ES2024 growable SharedArrayBuffer). */ export const growableSharedBuffer = sharedBufferAvailable && typeof SharedArrayBuffer.prototype.grow === "function"; /** - * Allocate a buffer that can grow in place up to `maxByteLength` (a growable - * SharedArrayBuffer where supported, a plain SharedArrayBuffer where SABs exist but can't - * grow, else an ArrayBuffer). The ceiling reserves address space, not committed memory. - * - * Pass `resizable: false` for buffers whose bytes are uploaded to the GPU: WebGL's - * `bufferData`/`bufferSubData` reject views over a resizable ArrayBuffer, so those must be - * fixed-size and grow by re-allocation instead (see {@link GrowableBuffer}). + * Allocate a buffer that can grow in place up to `maxByteLength`. The ceiling + * reserves address space, not committed memory. Pass `resizable: false` for + * GPU-uploaded buffers (WebGL rejects views over a resizable ArrayBuffer). */ export function makeGrowableBuffer( byteLength: number, @@ -51,11 +35,7 @@ export function makeGrowableBuffer( return new ArrayBuffer(byteLength); } -/** - * Grow `raw` in place to at least `byteLength`; returns false when it can't (growable - * shared buffers unsupported, or `byteLength` exceeds `maxByteLength`) so the caller - * re-allocates a fresh, larger buffer and re-publishes it. - */ +/** Grow `raw` in place to at least `byteLength`. Returns false when it can't. */ export function growBuffer( raw: SharedArrayBuffer | ArrayBuffer, byteLength: number, @@ -73,21 +53,13 @@ export function growBuffer( return true; } -/** - * Invoked when a {@link GrowableBuffer} had to be re-allocated rather than grown in place. - * The main thread must swap to `raw` (now holding `capacity` records) and re-attach its - * version watcher, so the worker wires this to post a dedicated re-publish message. - * (In-place growth fires nothing: the main thread already holds the same, now-larger - * buffer, and its length-tracking views auto-extend.) - */ +/** Invoked when a buffer was re-allocated (the main thread must swap to the new buffer). */ export type RepublishHandler = ( raw: SharedArrayBuffer | ArrayBuffer, capacity: number, ) => void; -/** Loud guard for the misconfiguration where a buffer overflows its ceiling but no - * handler was wired to re-publish the re-allocated buffer, far better than silently - * leaving the main thread reading a stale, detached buffer. */ +/** Guard: overflow without a republish handler is a misconfiguration. */ const throwOnUnhandledRepublish: RepublishHandler = () => { throw new Error( "GrowableBuffer overflowed its maxByteLength but no republish handler was provided, so the re-allocated buffer cannot reach the main thread.", @@ -95,15 +67,10 @@ const throwOnUnhandledRepublish: RepublishHandler = () => { }; /** - * SharedArrayBuffer-backed store with a `[version:int32]` header followed by fixed-size - * records, that grows on demand. Subclasses fix the header/record byte sizes and re-bind - * their own field views in {@link GrowableBuffer.bindRecordViews}; everything about - * growing, re-publishing, and the atomic version handshake lives here. - * - * `resizable: false` makes every allocation a fixed SharedArrayBuffer, required for buffers - * uploaded to the GPU (WebGL rejects views over resizable ArrayBuffers). Such a buffer - * can't `.grow` in place, so {@link ensureCapacity} always takes the re-allocate + - * re-publish path; the main thread swaps to the new (still fixed, uploadable) buffer. + * SharedArrayBuffer-backed store with a `[version:int32]` header followed by + * fixed-size records, that grows on demand. Subclasses define their record + * layout via {@link bindRecordViews}. Pass `resizable: false` for GPU-uploaded + * buffers that must re-allocate instead of growing in place. */ export abstract class GrowableBuffer { /** The raw buffer; reassigned (and re-published) when it must be re-allocated. */ @@ -143,13 +110,7 @@ export abstract class GrowableBuffer { return (this.raw.byteLength - this.headerBytes) / this.recordBytes; } - /** - * Ensure room for `capacity` records. A `resizable` buffer grows in place with no - * message where possible (length-tracking views auto-extend). Otherwise (a non- - * resizable (GPU) buffer, or one past its ceiling) it re-allocates a larger buffer, - * copies the bytes across, re-binds the views, and re-publishes so the main thread - * swaps to (and re-watches) the new buffer. - */ + /** Ensure room for `needed` records, growing or re-allocating as necessary. */ ensureCapacity(needed: number): void { const neededBytes = this.headerBytes + needed * this.recordBytes; if (growBuffer(this.raw, neededBytes)) { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts index fdbecfffd43..876df0841fa 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.test.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; import { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts index b05624faa64..c0d68596207 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/buffers/position-buffer.ts @@ -1,18 +1,12 @@ /* eslint-disable no-bitwise */ /** - * SharedArrayBuffer-backed position storage shared by every layout engine - * (d3-force entity layouts and the WebCola cluster layout). + * SharedArrayBuffer-backed position storage. * - * Layout: [version: int32 (4 bytes)] [x0, y0, x1, y1, ... : float32] + * Layout: `[version: int32] [x0, y0, x1, y1, ... : float32]` * - * The worker fills {@link PositionBuffer.positions} and calls - * {@link PositionBuffer.commit}; the version counter (bumped atomically and - * notified) lets the main thread detect changes via Atomics.waitAsync and read - * the same memory with zero copying. When SharedArrayBuffer is unavailable we - * fall back to a plain ArrayBuffer whose contents are messaged across. - * - * The growable SharedArrayBuffer primitives ({@link makeGrowableBuffer}, grow-or-republish) live in - * growable-buffer.ts; {@link FlatGraphBuffer} subclasses {@link GrowableBuffer} for them. + * The worker fills positions and calls {@link PositionBuffer.commit}; the + * version counter (atomic + notify) lets the main thread detect changes + * via `Atomics.waitAsync` with zero copying. */ import { @@ -53,19 +47,9 @@ export class PositionBuffer { } /** - * Per-leaf entity-dot buffer: a 4-byte version header, then one INTERLEAVED record per node: - * - * [version:i32] then count records of { x:f32, y:f32, rgba:u8 x4 } (12 bytes each) - * - * Positions stream from the force layout each tick ({@link setPosition}); the worker writes the - * per-node colour once it knows it ({@link setColor}), so a selection focus-dim can mutate one - * node's colour in place without a rebuild. The renderer reads BOTH straight off the buffer as - * Deck binary attributes -- see {@link leafPositionAttribute} / {@link leafColorAttribute}. - * - * Distinct from the positions-only {@link PositionBuffer} (still used by the macro cluster - * layout, which carries no colour) and from {@link FlatGraphBuffer} (one whole-graph, - * growable, with radius + entityIdx too). A leaf's node set is fixed for the layout's life - * (it is recreated wholesale on a count change), so this buffer is non-growable. + * Per-leaf entity-dot buffer: interleaved records of `{ x:f32, y:f32, rgba:u8x4 }` + * (12 bytes each) after a 4-byte version header. Non-growable; recreated on + * count change. */ export const LEAF_HEADER_BYTES = 4; export const LEAF_RECORD_BYTES = 12; @@ -161,20 +145,12 @@ export function leafColorAttribute(raw: SharedArrayBuffer | ArrayBuffer): { } /** - * Interleaved layout of the flat-tier shared buffer. All per-node GPU data lives in one - * buffer as a header plus fixed-size records: + * Flat-tier interleaved shared buffer. Header plus fixed-size records: * - * [version:i32][count:u32] then count records of - * { x:f32, y:f32, radius:f32, rgba:u8 x4, entityIdx:u32 } (20 bytes each) + * `[version:i32][count:u32]` then count records of + * `{ x:f32, y:f32, radius:f32, rgba:u8x4, entityIdx:u32 }` (20 bytes each) * - * `entityIdx` is the join key: it maps a rendered record back to its entity (the - * main thread pairs it with the EntityIdx->EntityId map buffer, see entity-id-buffer.ts). - * - * Interleaving (one record per node) is what makes the buffer growable: appending - * a node is "write one record at index `count`, bump `count`, one atomic sync", - * no region shuffling. The renderer reads each field straight off this buffer via - * stride/offset (the constants below), so there is never a gather or a copy. - * (`version` must be the Int32Array view, Atomics.notify only accepts that.) + * `entityIdx` is the join key mapping a rendered record back to its entity. */ export const FLAT_HEADER_BYTES = 8; export const FLAT_RECORD_BYTES = 20; @@ -186,17 +162,9 @@ export const FLAT_ENTITYIDX_BYTE_OFFSET = 16; const FLAT_RECORD_SLOTS = FLAT_RECORD_BYTES / 4; /** - * SharedArrayBuffer-backed, interleaved store for the flat-tier graph. The layout writes - * positions each tick; the worker writes radius/colour/count on commit. Per-node - * updates (a settling position, a degree-driven radius, an interaction highlight) - * are written in place, never via a structure-frame round-trip. - * - * This buffer is uploaded to the GPU each frame (the renderer reads its records as - * Deck.gl binary attributes), and WebGL rejects views over a resizable ArrayBuffer, so - * it is non-resizable (`resizable: false`). `capacity` may exceed the live `count` so - * streamed nodes append into spare records in place; outgrowing it goes through - * {@link GrowableBuffer.ensureCapacity}'s re-allocate + re-publish path (a fresh, still - * fixed, uploadable buffer), not an in-place `.grow`. + * Growable, interleaved store for the flat-tier graph. Non-resizable + * (GPU-uploaded); outgrowing capacity re-allocates via + * {@link GrowableBuffer.ensureCapacity}. */ export class FlatGraphBuffer extends GrowableBuffer { /** `count` header slot. */ @@ -222,11 +190,7 @@ export class FlatGraphBuffer extends GrowableBuffer { this.bindRecordViews(this.raw); } - /** - * Re-point the field views at `raw`. Re-runs on every re-allocation (this buffer never - * grows in place, it is non-resizable for GPU upload). `#count` keeps its explicit - * length 1 (it is a single header slot). - */ + /** Re-point field views at the (re-allocated) buffer. */ protected override bindRecordViews( raw: SharedArrayBuffer | ArrayBuffer, ): void { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts index ef88142dfe8..83b54915cb2 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/bitset.ts @@ -4,8 +4,6 @@ /** * Fixed-universe bit set backed by a Uint32Array. * - * Used for type ancestor closures: each bit position corresponds to a - * TypeIdx, and set membership means "this type is an ancestor." * Operations (or, and, intersectionCount, jaccard) are all O(words) * where words = ceil(universe / 32). */ diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts index dbe0fd6b913..14347d06b77 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/column.ts @@ -1,9 +1,3 @@ -/** - * A typed array constructor that can create views over a SharedArrayBuffer. - * - * This is the type-level trick that makes Column generic: pass the constructor - * as a value, and TypeScript infers the element type from it. - */ type BackingBuffer = SharedArrayBuffer | ArrayBuffer; const sharedBufferAvailable = typeof SharedArrayBuffer !== "undefined"; @@ -114,7 +108,7 @@ export class Column { push(value: T): number { if (this.#length >= this.#view.length) { - this.#grow(); + this.#grow(this.#length + 1); } const idx = this.#length; @@ -159,6 +153,38 @@ export class Column { return new ColumnView(filled.subarray(start, end) as A); } + /** + * Remove the first occurrence of `value` by swapping it with the last + * element and shrinking by one. O(n) scan, O(1) removal. Returns true + * if the value was found. + */ + swapRemove(value: T): boolean { + for (let i = 0; i < this.#length; i++) { + if (this.#view[i] === value) { + this.#length--; + if (i < this.#length) { + this.#view[i] = this.#view[this.#length]!; + } + return true; + } + } + return false; + } + + /** Append all elements from one or more columns. */ + append(...columns: Column[]): void { + let total = this.#length; + for (const column of columns) { + total += column.#length; + } + + this.#ensureCapacity(total); + for (const column of columns) { + this.#view.set(column.#view.subarray(0, column.#length), this.#length); + this.#length += column.#length; + } + } + /** Copy the filled portion (or a sub-range) into a new independent Column. */ slice(start?: number, end?: number): Column { const source = this.subarray(start, end); @@ -169,8 +195,18 @@ export class Column { return col; } - #grow(): void { - const newCapacity = Math.max(1, this.#view.length * 2); + #ensureCapacity(needed: number): void { + if (needed <= this.#view.length) { + return; + } + this.#grow(needed); + } + + #grow(minCapacity: number): void { + let newCapacity = Math.max(1, this.#view.length * 2); + while (newCapacity < minCapacity) { + newCapacity *= 2; + } const newByteLength = newCapacity * this.#ctor.BYTES_PER_ELEMENT; if ( diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts index 0ed69512752..b479e954679 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/interner.ts @@ -1,9 +1,6 @@ /** - * Bidirectional string interner. - * - * Assigns a stable integer index to each unique string value. - * Used for entity IDs, type-set keys, and versioned URLs to avoid - * storing millions of repeated string references. + * Bidirectional interner: assigns a stable integer index to each + * unique value, and supports reverse lookup by index. */ export class Interner { readonly #map: Map; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.test.ts new file mode 100644 index 00000000000..382d1309a8a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.test.ts @@ -0,0 +1,53 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- vitest is provided by the monorepo; the frontend's own test runner is not yet wired up. +import { describe, expect, it } from "vitest"; + +import { ReadonlySortedSet } from "./readonly-sorted-set"; + +const numericCmp = (a: number, b: number) => a - b; + +const setOf = (...values: number[]) => + new ReadonlySortedSet(values, numericCmp); + +describe("ReadonlySortedSet", () => { + describe("isSubsetOf", () => { + it("empty is a subset of everything", () => { + expect(setOf().isSubsetOf(setOf(1, 2, 3))).toBe(true); + }); + + it("empty is a subset of empty", () => { + expect(setOf().isSubsetOf(setOf())).toBe(true); + }); + + it("equal sets are subsets of each other", () => { + expect(setOf(1, 2, 3).isSubsetOf(setOf(1, 2, 3))).toBe(true); + }); + + it("proper subset with gaps in the superset", () => { + expect(setOf(1, 3, 5).isSubsetOf(setOf(1, 2, 3, 4, 5, 6))).toBe(true); + }); + + it("single element present", () => { + expect(setOf(3).isSubsetOf(setOf(1, 2, 3, 4))).toBe(true); + }); + + it("single element missing", () => { + expect(setOf(7).isSubsetOf(setOf(1, 2, 3, 4))).toBe(false); + }); + + it("rejects when one element is missing", () => { + expect(setOf(1, 2, 3).isSubsetOf(setOf(1, 3))).toBe(false); + }); + + it("rejects a superset", () => { + expect(setOf(1, 2, 3).isSubsetOf(setOf(1, 2))).toBe(false); + }); + + it("rejects disjoint sets", () => { + expect(setOf(1, 2).isSubsetOf(setOf(3, 4))).toBe(false); + }); + + it("handles duplicates in input (deduped by constructor)", () => { + expect(setOf(1, 1, 2, 2).isSubsetOf(setOf(1, 2, 3))).toBe(true); + }); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts index 08c657367ae..17074c643a9 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/collections/readonly-sorted-set.ts @@ -25,13 +25,7 @@ function binarySearch( return -1; } -/** - * An immutable, deduplicated, sorted set of numbers. - * - * Constructed once with deduplication and sorting, then read-only. - * Used for type-set keys: the canonical representation of an entity's - * direct type indices. - */ +/** Immutable, deduplicated, sorted set. Constructed once, then read-only. */ export class ReadonlySortedSet { readonly #items: readonly T[]; readonly #compare: (lhs: T, rhs: T) => number; @@ -66,7 +60,7 @@ export class ReadonlySortedSet { if (comparison === 0) { lhsIdx++; rhsIdx++; - } else if (comparison < 0) { + } else if (comparison > 0) { rhsIdx++; } else { return false; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts index 3e10c00250e..20314c0d59a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts @@ -34,10 +34,10 @@ import { } from "../hierarchy/distinctive-cluster-label"; import { LodState, computeVisibleCut } from "../hierarchy/lod"; import { createClusterLayout } from "../layout/cluster-layout"; -import { createCommunityLayout } from "../layout/community-layout"; import { createEntityLayout } from "../layout/entity-layout"; import { createFlatLayout } from "../layout/flat-layout"; import { sharedBufferAvailable } from "../layout/force-simulation"; +import { createStressLayout } from "../layout/stress-layout"; import { optimizeTopLevel } from "../layout/top-level-layout"; import { untangleLayout } from "../layout/untangle"; import { EntityStore } from "../store/entity"; @@ -61,7 +61,13 @@ import type { RenderFlatGraph, StructureFrame, } from "../../frames"; -import type { EntityIndex, TypeSetId, TypeSetKey, VizMode } from "../../ids"; +import type { + EntityIndex, + LinkId, + TypeSetId, + TypeSetKey, + VizMode, +} from "../../ids"; import type { RepublishHandler } from "../buffers/growable-buffer"; import type { Port } from "../geometry/bubble-ports"; import type { EdgeFrame } from "../geometry/edge-aggregation"; @@ -148,12 +154,7 @@ const FLAT_SEED_NEIGHBOUR_OFFSET = 24; const FLAT_SEED_DISK_SCALE = 28; /** Flat-tier edge stroke width in world units (the layer scales it with zoom). */ const FLAT_EDGE_WIDTH_WORLD = 1.2; -/** - * SharedArrayBuffer capacity for a flat (re)build, over-allocated past the live - * count so the community-force tier can append streamed nodes into spare records - * (warm FA2 absorb: bump count + version, one atomic sync, no buffer realloc / - * re-send). Geometric headroom, so a rebuild only happens when a batch overflows it. - */ +/** Over-allocate capacity so streamed nodes can append without reallocation. */ function flatCapacityFor(count: number): number { return Math.max(count + 64, Math.ceil(count * 1.5)); } @@ -169,22 +170,16 @@ export class GraphWorker { readonly #types: TypeRegistry = new TypeRegistry(); readonly #typeSets: TypeSetStore = new TypeSetStore(); - /** Wired into the EntityStore's EntityIdx->EntityId join map: on the rare re-allocation - * (past its ceiling), re-send the buffer so the main thread swaps to it. The same - * message as the first publish; the map is read on demand, so "here is the current - * buffer" is all the main thread needs. */ + /** Re-publishes the EntityIdx->EntityId join map buffer on reallocation. */ readonly #republishEntityIdMap: RepublishHandler = (raw, capacity) => { this.#onLayoutMessage?.({ type: "ENTITY_ID_MAP", buffer: raw, capacity }); }; - /** Owns interning and the join map (it writes each EntityId the instant it assigns the - * EntityIdx, so the map is always current, no mirror pass). The worker just publishes - * the buffer to the main thread (once, plus the republish handler above). */ + /** The join map is always current: each EntityId is written on intern. */ readonly #entities: EntityStore = new EntityStore(this.#republishEntityIdMap); - /** Whether the first ENTITY_ID_MAP publish has gone out. */ #entityIdMapPublished = false; readonly #links: LinkStore = new LinkStore(); - /** Per-entity property features + property titles, used to NAME embedding clusters. */ + /** Per-entity property features + property titles, used to name clusters. */ readonly #properties: PropertyStore = new PropertyStore(); readonly #clusterTree = new ClusterTree(); @@ -208,12 +203,9 @@ export class GraphWorker { readonly #untangled = new Set(); /** - * Last committed LOCAL positions of the root's top-level children, keyed by - * cluster id. Persisted across layout recreation AND hierarchy rebuilds (which - * recreate family nodes as fresh objects), so the top level keeps its - * arrangement when a cluster is added/removed instead of being re-solved from - * scratch. Read to warm-seed a recreated root layout and to anchor - * {@link optimizeTopLevel}; refreshed from the live layout on every commit. + * Last committed local positions of the root's top-level children. Persisted + * across layout recreation and hierarchy rebuilds so the top level keeps its + * arrangement when a cluster is added or removed. */ readonly #topLevelPositions = new Map(); @@ -226,12 +218,10 @@ export class GraphWorker { * later); everyone else dims. Empty = no highlight. Set via {@link setHighlight}. */ #highlightedEntities = new Set(); - /** Set when an expand flips an already-rendered frontier node to a root. The flat tier restyles - * every commit, but the hierarchical leaf path does not, so the worker re-applies styling after - * the commit when this is set (see {@link restyleIfRootsFlipped}). */ + /** Set when an expand flips a rendered frontier node to a root; triggers a restyle. */ #rootFlipPending = false; #mode: VizMode = "flat-force"; - /** Count of loaded NODE entities (excludes interned links). See {@link nodeCount}. */ + /** Loaded node entities (excludes interned links). */ #nodeEntityCount = 0; /** True when the committed state is the hierarchical (cluster-tree) regime. */ #hierarchicalActive = false; @@ -240,12 +230,7 @@ export class GraphWorker { #flatRenderEdges: FlatRenderEdge[] = []; /** Interleaved SharedArrayBuffer backing the flat layout (positions + radii + colours). */ #flatBuffer: FlatGraphBuffer | undefined; - /** - * Wired into every flat {@link FlatGraphBuffer}: when it outgrows its capacity it - * re-allocates (the buffer is non-resizable; its bytes are GPU-uploaded, and WebGL - * rejects views over a resizable buffer), so hand the main thread the new buffer to swap - * to + re-watch. Appends within capacity fire nothing (they fill spare records in place). - */ + /** Re-publishes the flat layout buffer on reallocation. */ readonly #republishFlatBuffer: RepublishHandler = (raw, capacity) => { this.#onLayoutMessage?.({ type: "BUFFER_REPUBLISHED", @@ -272,9 +257,8 @@ export class GraphWorker { /** Cached topology from the last structure commit; reused by position ticks. */ #cutIndex: CutIndex | undefined; #edgeFrame: EdgeFrame | undefined; - /** Per-lane link-entity unions for the CURRENT structure, indexed by laneId. A merged - * highway's lanes all share one union (the whole ribbon's links); set by #buildHighwayLanes, - * read by highwayLinks on click. */ + /** Per-lane link-entity unions, indexed by laneId. A merged highway's + * lanes share one union (the whole ribbon's links). */ #highwayLaneUnions: EntityIndex[][] = []; // MessageChannel-based simulation scheduler. @@ -397,11 +381,10 @@ export class GraphWorker { /** * One simulation step across all active layouts. * - * Entity layouts write their positions to a SharedArrayBuffer and notify the - * main thread directly (no message, no frame). Cluster layouts write back to - * their child circles; when any cluster moved, a PositionsFrame is emitted so - * the bubbles and the highways that follow them stay in sync. The expensive - * topology pipeline (cut, CutIndex, aggregation) is never touched here. + * Entity layouts stream positions via SharedArrayBuffer. Cluster layouts + * write back to child circles; when any cluster moved, a PositionsFrame is + * emitted. The topology pipeline (cut, CutIndex, aggregation) is not + * touched here. */ #tickAllLayouts(): void { const tickStart = performance.now(); @@ -420,14 +403,11 @@ export class GraphWorker { if (kind === "entities") { if (changed && clusterId === FLAT_LAYOUT_ID) { - // Flat edges are worker-built beziers, so emit a frame when the layout - // moves so they track the shared-buffer-streamed dots. (Non-shared-buffer - // periodic sync of the interleaved flat buffer is deferred, see MANIFESTO.) + // Flat edges are worker-built beziers; emit a frame so they + // track the moved dots. flatMoved = true; } else if (changed && !sharedBufferAvailable) { - // Hierarchical entity layout, non-shared-buffer fallback: post position - // snapshots. With a SharedArrayBuffer the buffer is written in place and - // the main thread is woken via Atomics. + // Non-shared-buffer fallback: post position snapshots. const positions = new Float32Array(layout.nodes.length * 2); for (let idx = 0; idx < layout.nodes.length; idx++) { const node = layout.nodes[idx]!; @@ -469,35 +449,23 @@ export class GraphWorker { const clustersJustSettled = clustersRunningBefore && !this.#anyClusterLayoutRunning(); if (clusterMoved || clustersJustSettled) { - // Recompose world positions top-down first (authoritative, see - // #syncWorldPositions), so anchor re-aiming below reads correct, - // fully-propagated circles even through settled depth >= 2 layouts. + // Recompose world positions top-down so anchor re-aiming reads correct, + // fully-propagated circles through settled nested layouts. this.#syncWorldPositions(); if (clusterMoved) { - // Re-aim opened sub-clusters' port anchors at their (moved) external - // neighbours, in place (no re-run, no structure emit). Everything - // positional (cluster positions, geometry, and entity fan-out exits + - // force targets) travels in the PositionsFrame; structure is re-emitted - // only on a real cut change (never per tick, that was the OOM). + // Re-aim opened sub-clusters' port anchors at their moved neighbours. this.#updateAnchorTracking(); } this.#emitPositions(); } else if (flatMoved) { - // Flat tier: no clusters, no world-sync, just refresh the edge beziers - // from the moved node positions (the dots themselves stream via the - // shared buffer). this.#emitPositions(); } - // Keep ticking while any layout is running, including an entity layout just - // re-energised by a moved port target (it hasn't ticked this turn, so the - // old "did a layout tick?" check would wrongly stop the scheduler). if (!this.#anyLayoutRunning()) { this.#schedulerRunning = false; } - // Gated so a settled idle doesn't spam: only slow ticks (force step + - // port/Bezier refresh) are worth seeing. + // Only log slow ticks. const elapsed = performance.now() - tickStart; if (this.debug && elapsed > SLOW_TICK_WARNING_MS) { // eslint-disable-next-line no-console @@ -512,11 +480,7 @@ export class GraphWorker { return this.#mode; } - /** - * Loaded NODE entities only. `EntityStore` interns links too (a link is an - * entity), so its `size` over-counts; the mode thresholds are defined on the - * non-link count (see `config`), so they must read this. - */ + /** Node entity count (excludes link entities interned by the EntityStore). */ get nodeCount(): number { return this.#nodeEntityCount; } @@ -526,11 +490,9 @@ export class GraphWorker { } /** - * The ego of a selected node: for each neighbor (from the full link store, so cross-cluster - * and both tiers are covered), the representative currently on screen -- the entity itself - * when individually rendered (flat, or an open leaf), else the visible cluster it collapses - * into. Neighbors not in view are omitted. The main thread highlights dots + bubbles from - * this. O(degree); per selection, not per frame. + * The ego of a selected node: for each neighbor, the representative currently + * on screen (the entity itself when individually rendered, or the visible + * cluster it collapses into). Neighbors not in view are omitted. */ ego(entityIdx: EntityIndex): EgoTarget[] { const cutIndex = this.#cutIndex; @@ -566,10 +528,7 @@ export class GraphWorker { this.#properties.registerTitles(propertySchemas); } - /** - * Insert a node entity. Returns null if duplicate, otherwise - * the entity index and the group it was assigned to. - */ + /** Insert a node entity. Returns undefined if duplicate. */ insertNodeEntity( entity: IngestEntity, ): { entityIdx: EntityIndex; groupKey: TypeSetKey } | undefined { @@ -658,7 +617,7 @@ export class GraphWorker { continue; } - // Snapshot count BEFORE insert so we can compute deltas. + // Snapshot count before insert so we can compute deltas. const group = this.#peekGroup(entity); if (group && !groupSnapshots.has(group.key)) { groupSnapshots.set(group.key, { @@ -691,10 +650,7 @@ export class GraphWorker { return deltas; } - /** - * Peek at which group an entity would land in without inserting. - * Used to snapshot counts before insert. - */ + /** Peek at which group an entity would land in without inserting. */ #peekGroup(entity: IngestEntity): TypeSetGroup | undefined { if (this.#entities.lookup(entity.entityId) !== undefined) { return undefined; // Already inserted, skip. @@ -761,12 +717,7 @@ export class GraphWorker { this.#cutIndex = undefined; this.#edgeFrame = undefined; - this.#clusterTree.rebuild( - this.#typeSets, - this.#types, - this.config, - this.nodeCount, - ); + this.#clusterTree.rebuild(this.#typeSets, this.#types, this.config); } /** @@ -779,7 +730,6 @@ export class GraphWorker { this.#typeSets, this.#types, this.config, - this.nodeCount, ); } @@ -792,10 +742,7 @@ export class GraphWorker { return this.#clusterTree.atomicSum(); } - /** - * Record a new viewport and react. Pan/zoom that doesn't change the LOD cut is handled by - * Deck.gl projection on the main thread; only an actual cut change commits new structure. - */ + /** Record a new viewport and commit if the LOD cut changed. */ handleViewport(viewport: ViewportState): void { this.#viewport = viewport; @@ -822,10 +769,7 @@ export class GraphWorker { } } - /** - * Pin a leaf cluster open (with its ancestors) regardless of zoom, until cleared with - * undefined -- the birds-eye view for a selection. Recomputes the cut if the pin changed. - */ + /** Pin a leaf cluster open (with its ancestors) regardless of zoom. */ pin(leafId: ClusterId | undefined): void { if (this.#pinnedLeaf === leafId) { return; @@ -855,9 +799,8 @@ export class GraphWorker { } /** - * Set the highlighted entities (a selection's ego now, a path later): they keep full colour - * and everyone else dims, so the highlight pops. Empty restores full colour. Generic -- the - * worker just dims the complement; the main thread decides what the set is. + * Set the highlighted entities. They keep full colour while everyone else + * dims. Empty set restores full colour. */ setHighlight(entityIdxs: readonly EntityIndex[]): void { this.#highlightedEntities = new Set(entityIdxs); @@ -883,11 +826,7 @@ export class GraphWorker { this.#emitPositions(); } - /** - * Re-style after an expand flipped an already-rendered frontier node to a root. The flat tier - * already restyled in {@link #commitFlat} (its `#writeFlatStyle` runs every commit); only the - * hierarchical tier, whose leaf colours are not rewritten for an unchanged leaf, needs this. - */ + /** Re-style after an expand flipped a frontier node to a root. */ restyleIfRootsFlipped(): void { if (!this.#rootFlipPending) { return; @@ -899,12 +838,9 @@ export class GraphWorker { } /** - * Commit a new topology: compute the visible cut (the only place that may - * subdivide and that mutates LOD state), create/destroy layouts, recompute - * edge aggregation, and emit a StructureFrame plus an initial PositionsFrame. - * - * Heavy O(entities)/O(links) work lives here and runs only on real topology - * changes (ingest, cut change, embedding result), never on a position tick. + * Commit a new topology: compute the visible cut, create/destroy layouts, + * recompute edge aggregation, and emit a StructureFrame plus an initial + * PositionsFrame. Runs only on topology changes, never on a position tick. */ commitStructure(opts?: { readonly deltas?: readonly IngestDelta[]; @@ -912,14 +848,10 @@ export class GraphWorker { }): void { this.recomputeMode(); - // The EntityStore keeps the EntityIdx->EntityId join map's contents current on intern; - // the worker only has to hand the main thread the buffer to read, once. this.#publishEntityIdMapOnce(); - // Flat tiers (flat-force / community-force) render the whole entity set as - // one individual-entity graph, no cluster tree (LAYOUT-MODES "Why three - // tiers"). flat-force and community-force are one regime; only crossing the - // hierarchical boundary tears the other regime's state down. + // Flat tiers render the whole entity set as one entity graph. Only + // crossing the hierarchical boundary tears the other regime's state down. if (this.#mode !== "hierarchical-lod") { if (this.#hierarchicalActive) { this.#tearDownHierarchical(); @@ -933,10 +865,8 @@ export class GraphWorker { this.#tearDownFlat(); } - // Cluster-tree maintenance lives here, not in entry.ts, so a tree rebuild - // can never clear the flat layout out from under flat mode. Rebuild on the - // first build, when re-entering the hierarchical regime, or when types - // changed; otherwise apply the incremental ingest deltas. + // Rebuild the tree on first build, re-entry, or type changes; + // otherwise apply incremental deltas. if (opts?.rebuildTree || !this.#hierarchicalActive || !this.hasClusters) { this.rebuildClusters(); } else if (opts?.deltas && opts.deltas.length > 0) { @@ -1062,11 +992,8 @@ export class GraphWorker { } /** - * Commit the flat-tier frame: the whole entity set as one individual-entity - * graph (no cluster tree). Builds/updates the single flat layout (warm-seeded, - * so streamed nodes are absorbed without a reshuffle), then emits the per-node - * style payload. Positions stream via the shared buffer, so the paired positions - * frame is trivial (it exists only to land the deferred structureVersion bump). + * Commit the flat tier: the whole entity set as one individual-entity graph. + * Builds or warm-updates the flat layout, then emits per-node style. */ #commitFlat(): void { const entityIdxs = this.#allNodeEntityIdxs(); @@ -1080,12 +1007,8 @@ export class GraphWorker { return; } - // Warm-absorb new nodes (community-force / FA2 only: additions, same engine, - // no removal), appending into the shared buffer's spare capacity when they fit, - // or swapping in a bigger shared buffer without losing solver state when they - // don't (both in #absorbFlatNodes). Else a full rebuild (first build, mode - // switch, the cola tier which can't absorb, or a removal). See MANIFESTO - // "True incremental". + // Community-force can warm-absorb additions without a full rebuild. + // Otherwise (first build, mode switch, removal) rebuild from scratch. const existing = this.#forceLayouts.get(FLAT_LAYOUT_ID); const priorBuffer = this.#flatBuffer; const modeChanged = this.#flatLayoutMode !== this.#mode; @@ -1129,11 +1052,7 @@ export class GraphWorker { this.#scheduleFlatLouvainLinger(); } - /** - * Emit the flat structure frame (count + Louvain membership for the BubbleSets) - * + its paired positions frame. Shared by the per-commit path and the trailing - * Louvain linger. (flat-force/cola has no `communities` -> undefined, no hulls.) - */ + /** Emit the flat structure frame (count + Louvain membership) and positions. */ #emitFlatFrame(layout: LayoutSimulation): void { this.#emitStructure([], { layoutId: FLAT_LAYOUT_ID, @@ -1146,10 +1065,8 @@ export class GraphWorker { } /** - * After community-force ingests go quiet for {@link FLAT_LOUVAIN_LINGER_MS}, run - * one trailing Louvain so the BubbleSets reflect the final graph; the last batch - * may not have crossed the growth-fraction refresh threshold. Each commit resets - * the timer, so it fires only once the stream settles. Position-neutral. + * After ingests go quiet for {@link FLAT_LOUVAIN_LINGER_MS}, run one trailing + * Louvain so BubbleSets reflect the settled graph. */ #scheduleFlatLouvainLinger(): void { if (this.#mode !== "community-force") { @@ -1167,11 +1084,7 @@ export class GraphWorker { }, FLAT_LOUVAIN_LINGER_MS); } - /** - * All loaded node entities, sorted by index for a deterministic shared-buffer - * order. Link entities live in no type-set group, so iterating the groups yields - * exactly the nodes. - */ + /** All loaded node entities, sorted by index. */ #allNodeEntityIdxs(): EntityIndex[] { const result: EntityIndex[] = []; for (const group of this.#typeSets) { @@ -1183,11 +1096,7 @@ export class GraphWorker { return result; } - /** - * Publish the join-map SharedArrayBuffer to the main thread the first time (later - * re-allocations re-publish via {@link #republishEntityIdMap}). The EntityStore keeps - * the buffer's contents current on intern, so this is purely "here is the buffer to read." - */ + /** Publish the join-map SharedArrayBuffer to the main thread on first use. */ #publishEntityIdMapOnce(): void { if (this.#entityIdMapPublished) { return; @@ -1202,38 +1111,35 @@ export class GraphWorker { } /** - * (Re)build the flat layout over the given node set, warm-seeded from the - * current layout's positions so existing nodes don't move and a streamed node - * appears beside an already-placed neighbour (incremental absorb, LAYOUT-MODES - * "Incremental loading"). Replaces the shared buffer (LAYOUT_DESTROYED + LAYOUT_CREATED). + * (Re)build the flat layout over the given node set. Warm-seeded from the + * current layout's positions so existing nodes stay in place. */ #rebuildFlatLayout(entityIdxs: readonly EntityIndex[]): void { const previous = this.#forceLayouts.get(FLAT_LAYOUT_ID); - const priorPositions = new Map(); + const priorPositions = new Map(); if (previous) { for (const node of previous.nodes) { - priorPositions.set(Number(node.id), [node.x ?? 0, node.y ?? 0]); + priorPositions.set(Number(node.id) as EntityIndex, [ + node.x ?? 0, + node.y ?? 0, + ]); } } const nodes = this.#seedFlatNodes(entityIdxs, priorPositions); const edges = this.#buildEntityEdges([...entityIdxs], nodes); - // One interleaved shared buffer for all per-node data; the layout writes - // positions into it, the worker writes radius/colour. Over-allocated past the - // live count so community-force can append streamed nodes in place (warm FA2 - // absorb: bump count + version, no realloc); overflowing the headroom rebuilds here. + // One interleaved shared buffer for all per-node data. Over-allocated + // so community-force can append streamed nodes without reallocation. const buffer = new FlatGraphBuffer( flatCapacityFor(nodes.length), this.#republishFlatBuffer, ); buffer.setCount(nodes.length); - // flat-force uses cola (best layout for small N); community-force uses the - // FA2 pipeline (Louvain -> SMACOF seed -> FA2) that scales past cola's O(N²). - // Both fill the same shared buffer, so everything downstream (style, edges, - // render) is identical; the tiers differ only in engine and (next) BubbleSets. + // flat-force uses cola; community-force uses FA2. Both fill the same + // shared buffer; downstream style/edges/render are identical. const layout = this.#mode === "community-force" - ? createCommunityLayout(nodes, edges, buffer, this.config.fa2) + ? createStressLayout(nodes, edges, buffer) : createFlatLayout(nodes, edges, buffer); if (previous) { @@ -1260,60 +1166,50 @@ export class GraphWorker { } /** - * Warm-absorb newly-arrived nodes into the live community-force (FA2) layout: - * seed them against the layout's current positions (existing nodes echo where - * they are; new nodes land beside their placed neighbours), then hand the layout - * the new nodes + the full edge set. The shared buffer was over-allocated, so the - * appended records land in spare capacity and {@link #writeFlatStyle}'s count + - * version bump publishes them in one atomic sync: no buffer realloc, no - * LAYOUT_CREATED, no cold restart (the growable-shared-buffer win). community-force - * only; {@link #commitFlat} gates everything else to {@link #rebuildFlatLayout}. + * Absorb newly-arrived nodes into the live community-force layout without a + * full rebuild. New nodes are seeded beside placed neighbours; appended + * records land in the buffer's spare capacity. */ #absorbFlatNodes( layout: LayoutSimulation, entityIdxs: readonly EntityIndex[], ): void { - const priorPositions = new Map(); + const priorPositions = new Map(); for (const node of layout.nodes) { - priorPositions.set(Number(node.id), [node.x ?? 0, node.y ?? 0]); + priorPositions.set(Number(node.id) as EntityIndex, [ + node.x ?? 0, + node.y ?? 0, + ]); } const seeded = this.#seedFlatNodes(entityIdxs, priorPositions); const currentIds = new Set(layout.nodeIds); const newNodes = seeded.filter((node) => !currentIds.has(node.id)); const edges = this.#buildEntityEdges([...entityIdxs], seeded); - // Within capacity, appended records land in spare shared-buffer space: no realloc, no - // re-send (#writeFlatStyle's count + version bump publishes them in one atomic sync). - // Over it, ensureCapacity re-allocates a bigger buffer (the flat shared buffer is - // non-resizable for GPU upload, so it can't grow in place), copying the records across; - // the layout holds the same FlatGraphBuffer instance, so its warm FA2 state rides across - // (only the instance's raw is swapped under it), and #republishFlatBuffer hands the new - // buffer to the main thread. No cold restart either way. + // If the new count exceeds capacity, re-allocate (the flat shared buffer + // is non-resizable for GPU upload). The layout's warm state is preserved. if (this.#flatBuffer && entityIdxs.length > this.#flatBuffer.capacity) { this.#flatBuffer.ensureCapacity(flatCapacityFor(entityIdxs.length)); } layout.absorb?.(newNodes, edges); this.#flatLinkCount = this.#links.count; - // absorb() re-energises the layout (status -> running), but the scheduler may - // have stopped when it last settled, so re-kick it, or the new nodes sit frozen - // at their seed (the "added but no more ticks, edges stay long" symptom). + // absorb() re-energises the layout, but the scheduler may have stopped + // when it last settled, so re-kick it. this.#ensureSchedulerRunning(); } /** - * Seed positions for a flat (re)build: keep every already-placed node where it - * is; place a new node beside a placed neighbour (deterministic offset); and - * fall back to a phyllotaxis disk for nodes with no placed neighbour (cold - * start, or a new orphan). WebCola majorises from this warm seed, so the layout - * absorbs streamed nodes instead of reshuffling. + * Seed positions for a flat (re)build. Already-placed nodes keep their + * position; new nodes land beside a placed neighbour; orphans fall back + * to a phyllotaxis disk. */ #seedFlatNodes( entityIdxs: readonly EntityIndex[], - priorPositions: ReadonlyMap, + priorPositions: ReadonlyMap, ): ForceNode[] { const goldenAngle = Math.PI * (3 - Math.sqrt(5)); - const placed = new Map(); + const placed = new Map(); for (const idx of entityIdxs) { const prior = priorPositions.get(idx); if (prior) { @@ -1330,7 +1226,7 @@ export class GraphWorker { continue; } for (const link of this.#links.linksFor(idx)) { - const neighbour = placed.get(link.otherId as number); + const neighbour = placed.get(link.otherId); if (neighbour) { const angle = idx * goldenAngle; placed.set(idx, [ @@ -1359,7 +1255,7 @@ export class GraphWorker { return entityIdxs.map((idx) => { const position = placed.get(idx)!; - const degree = this.#links.linksFor(idx).length; + const degree = this.#links.degreeOf(idx); return { id: String(idx), x: position[0], @@ -1369,11 +1265,7 @@ export class GraphWorker { }); } - /** - * Write per-node radius + colour into the interleaved shared buffer (one record - * per node), set the live count, and publish. Runs each commit, so a type change - * recolours in place without a structure round-trip. - */ + /** Write per-node radius + colour into the interleaved shared buffer. */ #writeFlatStyle(layout: LayoutSimulation, buffer: FlatGraphBuffer): void { const colorByGroup = new Map(); for (let idx = 0; idx < layout.nodes.length; idx++) { @@ -1394,25 +1286,24 @@ export class GraphWorker { } /** - * Build the flat render edges (one per link, both endpoints in the node set): - * local node indices + the link's own type colour. Rebuilt each commit so - * colours track type changes; the per-tick geometry reads the two nodes' - * current positions for each. + * Build the flat render edges: local node indices + link-type colour + * for each link whose endpoints are both in the node set. */ #buildFlatRenderEdges(layout: LayoutSimulation): FlatRenderEdge[] { - const localOf = new Map(); + const localOf = new Map(); for (let idx = 0; idx < layout.nodeIds.length; idx++) { - localOf.set(Number(layout.nodeIds[idx]), idx); + localOf.set(Number(layout.nodeIds[idx]) as EntityIndex, idx); } + const colorCache = new Map(); - const seenLinks = new Set(); + const seenLinks = new Set(); const edges: FlatRenderEdge[] = []; for (const [entityIdx, sourceIdx] of localOf) { - for (const link of this.#links.linksFor(entityIdx as EntityIndex)) { + for (const link of this.#links.linksFor(entityIdx)) { if (seenLinks.has(link.linkId)) { continue; } - const targetIdx = localOf.get(link.otherId as number); + const targetIdx = localOf.get(link.otherId); if (targetIdx === undefined) { continue; } @@ -1425,14 +1316,11 @@ export class GraphWorker { }); } } + return edges; } - /** - * Fill the sink with one straight cubic per flat render lane from the nodes' - * current positions, coloured by link type, width in world units. Runs each - * position tick so the edges track the dots as the layout settles. - */ + /** Emit one straight cubic per flat render edge from current node positions. */ #buildFlatEdgeBeziers( sink: BezierSegmentSink, arrowsOut: RenderEdgeArrow[], @@ -1478,8 +1366,7 @@ export class GraphWorker { const edgeTy = ty - uy * edgeEndInset; const visibleDx = edgeTx - sx; const visibleDy = edgeTy - sy; - // An edge stays full only when BOTH endpoints are highlighted; otherwise it dims with - // the field. (No highlight active -> every edge is full.) + // An edge stays full only when both endpoints are highlighted. const highlighted = this.#highlightedEntities; const full = highlighted.size === 0 || @@ -1529,8 +1416,7 @@ export class GraphWorker { return this.#colorForTypeGroup(groupIdx, cache); } - /** Hierarchy-aware NODE colour for a type-set group, cached. Keys off the type's ROOT (a family - * shares a hue) -- see {@link colorForType}. */ + /** Node colour for a type-set group, keyed off the type's root. */ #colorForTypeGroup(groupIdx: TypeSetId, cache: Map): Color { const cached = cache.get(groupIdx); if (cached) { @@ -1545,9 +1431,8 @@ export class GraphWorker { return color; } - /** EDGE colour for a link's type-set group, cached. Keys off the link's primary DIRECT type's OWN - * slot, NOT its root -- every link type shares the `Link` root, so root-colouring collapses all - * edges to one hue (see {@link edgeColorForType}). */ + /** Edge colour for a link's type-set group, keyed off the link's own type + * slot (not its root, since all link types share the `Link` root). */ #edgeColorForTypeGroup( groupIdx: TypeSetId, cache: Map, @@ -1585,12 +1470,7 @@ export class GraphWorker { } } - /** - * Destroy all hierarchical layouts + reset the render/edge state (entering the - * flat regime). The cluster tree is left to be rebuilt fresh on the next - * hierarchical commit (it reads the always-current type sets), so flat mode - * spends no time clearing it. - */ + /** Destroy all hierarchical layouts and reset render/edge state. */ #tearDownHierarchical(): void { for (const [id, kind] of this.#layoutKind) { if (kind === "entities") { @@ -1666,12 +1546,7 @@ export class GraphWorker { }); } - /** - * Per-lane summaries for the rendered highways, indexed by `laneId` (a visual - * edge's index in the current edge frame, which is also the `id` on the lane's - * bezier segments). Individual (non-aggregate) edges fill their slot with a - * placeholder so the index stays aligned with `laneId`. - */ + /** Per-lane summaries for the rendered highways, indexed by `laneId`. */ #buildHighwayLanes(): HighwayLaneSummary[] { const placeholder: HighwayLaneSummary = { typeId: null, @@ -1684,11 +1559,8 @@ export class GraphWorker { this.#highwayLaneUnions = []; return []; } - // A merged highway renders several aggregate lanes (different children/types) as ONE - // ribbon whose segments carry a single representative laneId. Group lanes by their - // highway-level endpoints -- the SAME analyzeHierarchy the renderer merges by -- so any - // segment of a ribbon resolves to the WHOLE ribbon's links + a combined summary. A direct - // (unmerged) lane is its own group, so it stays exact. + // Group lanes by highway-level endpoints so a merged highway's segments + // all resolve to the whole ribbon's links and a combined summary. const containerIds = this.#cutIndex?.containerIds ?? new Set(); const groups = new Map(); for (let idx = 0; idx < edges.length; idx++) { @@ -1702,10 +1574,8 @@ export class GraphWorker { this.#clusterTree, containerIds, ); - // A lane is single-type+direction. A merged highway collapses the SAME type+direction - // across several children into one ribbon, so the group key is (outer endpoints, type, - // direction) -- NOT endpoints alone, which would fold distinct single-type lanes together. - // A direct (unmerged) lane keys on its own visualKey (already per type+direction). + // Group key includes type+direction so distinct single-type lanes + // aren't folded together. Unmerged lanes key on their own visualKey. const outerSource = sourceContainers[sourceContainers.length - 1]?.containerId ?? edge.source.id; @@ -1737,7 +1607,7 @@ export class GraphWorker { if (!edge || edge.kind !== "aggregate") { continue; } - for (const entityIdx of edge.linkEntityIdxs) { + for (const entityIdx of edge.entities) { union.add(entityIdx); } count += edge.count; @@ -1764,10 +1634,8 @@ export class GraphWorker { } /** - * The link entities a clicked highway represents: the UNION of every aggregate lane that - * merged into the same rendered ribbon as `laneId` (so a merged highway opens ALL its links, - * not just the representative lane's). Precomputed per structure by {@link #buildHighwayLanes}; - * empty for an out-of-range / non-aggregate id. + * The link entities a clicked highway represents: the union of every + * aggregate lane merged into the same ribbon as `laneId`. */ highwayLinks(laneId: number): EntityIndex[] { return laneId >= 0 && laneId < this.#highwayLaneUnions.length @@ -1793,7 +1661,7 @@ export class GraphWorker { readonly sourceId: ClusterId; readonly targetId: ClusterId; totalCount: number; - readonly byType: Map; + readonly byType: Set; } >(); @@ -1810,12 +1678,12 @@ export class GraphWorker { const { key, sourceId, targetId } = makePairKey(hwSourceId, hwTargetId); let highway = highwayPairs.get(key); if (!highway) { - highway = { sourceId, targetId, totalCount: 0, byType: new Map() }; + highway = { sourceId, targetId, totalCount: 0, byType: new Set() }; highwayPairs.set(key, highway); } highway.totalCount += pair.totalCount; - for (const typeIdx of pair.byType.keys()) { - highway.byType.set(typeIdx, true); + for (const typeSetId of pair.byType.keys()) { + highway.byType.add(typeSetId); } } @@ -1827,10 +1695,7 @@ export class GraphWorker { ); } - /** - * Emit a PositionsFrame: bounded cluster positions plus freshly-computed - * highway/feeder Bezier geometry for the current positions. No aggregation. - */ + /** Emit a PositionsFrame with cluster positions and highway/feeder Bezier geometry. */ #emitPositions(): void { // Authoritative: recompose the opened subtree's world circles before any // positional read, so a moved ancestor reaches its whole subtree (incl. @@ -1924,11 +1789,7 @@ export class GraphWorker { }; } - /** - * The frontier (non-root) members of a cluster, as EntityIdxs. Members come - * from a direct index column or, for a type-keyed cluster, the union of its - * type-set groups (see {@link ClusterNode.membership}). - */ + /** The frontier (non-root) members of a cluster. */ #frontierMembers(cluster: ClusterNode): EntityIndex[] { const frontier: EntityIndex[] = []; const { membership } = cluster; @@ -1957,12 +1818,7 @@ export class GraphWorker { return frontier; } - /** - * Build per-leaf entity-edge topology for the structure frame: the internal - * entity-to-entity links (drawn from the shared buffer) plus the stable per-leaf - * style. Fan-out feeder endpoints are positional and ride the PositionsFrame instead - * (see {@link #buildEntityFanOut}), so this runs only on a cut change. - */ + /** Build per-leaf entity-edge topology for the structure frame. */ #buildEntityLayers(cutIndex: CutIndex): RenderEntityLayer[] { const layers: RenderEntityLayer[] = []; @@ -1974,9 +1830,9 @@ export class GraphWorker { continue; } - const localOf = new Map(); + const localOf = new Map(); for (let idx = 0; idx < layout.nodeIds.length; idx++) { - localOf.set(Number(layout.nodeIds[idx]), idx); + localOf.set(Number(layout.nodeIds[idx]) as EntityIndex, idx); } // Internal entity-to-entity links (both endpoints owned by this leaf). @@ -2011,10 +1867,8 @@ export class GraphWorker { return layers; } - // Write each leaf node's colour into its interleaved entity buffer, so the dots render - // per-node colour. Every node gets the leaf's cluster colour; an active highlight dims the - // non-ego nodes. Called on leaf-buffer creation and on highlight change only -- NOT per - // commit (that re-uploaded every open leaf on each zoom LOD crossing: a pan/zoom stutter). + // Write per-node colour into the leaf's entity buffer. Runs on leaf creation + // and highlight changes, not per commit (avoiding pan/zoom stutter). #writeLeafColors(cluster: ClusterNode, layout: LayoutSimulation): void { if (!layout.setNodeColor) { return; @@ -2038,23 +1892,17 @@ export class GraphWorker { /** * Fan-out feeder endpoints for the current positions (one entry per open - * leaf), and a refill of each leaf's port-attraction targets. Positional: it - * runs every position tick and rides the PositionsFrame, so the dots' exits - * (and the force pulling the dots toward them) track the ports as the macro - * layout settles, without re-emitting the structure. Per external owner, the - * exit is the leaf's own boundary point toward the highway port serving it, so - * the fan-out chains into the feeder -> highway. + * leaf), plus a refill of each leaf's port-attraction targets. The exit + * per external owner is the leaf's boundary point toward the highway port, + * chaining into the feeder. */ #buildEntityFanOut( cutIndex: CutIndex, ports: PortPairs, ): RenderEntityFanOut[] { const result: RenderEntityFanOut[] = []; - // While the macro is still moving, the ports drift continuously, so keep every - // dot layout warm so the dots track that drift instead of lagging it. (The - // old ">0.5 since last refill" threshold silently dropped slow drift: many - // sub-threshold steps accumulated into a large offset that never re-settled, - // the depth >= 2 "port at the old position" the macro's slow tail produced.) + // While the macro is still moving, keep dot layouts warm so dots + // track the continuous port drift instead of lagging it. const clustersRunning = this.#anyClusterLayoutRunning(); for (const leafId of cutIndex.entityModeIds) { @@ -2064,9 +1912,9 @@ export class GraphWorker { continue; } - const localOf = new Map(); + const localOf = new Map(); for (let idx = 0; idx < layout.nodeIds.length; idx++) { - localOf.set(Number(layout.nodeIds[idx]), idx); + localOf.set(Number(layout.nodeIds[idx]) as EntityIndex, idx); } const exitForOwner = new Map< @@ -2092,15 +1940,10 @@ export class GraphWorker { : portsFor(ports, hwSourceId, hwTargetId); let exit: readonly [number, number] | null = null; if (hp) { - // Aim the exit at the feeder's first waypoint, not at the outermost - // port directly. The feeder leaves this leaf toward its nearest - // enclosing open container's boundary (in the direction of the - // outermost port hp.a), then hops outward. Those coincide only when the - // leaf sits directly in the outermost container (depth 1); with an - // intermediate container (depth >= 2) aiming straight at hp.a lands the - // fan-out a few position-dependent degrees off where the feeder - // actually leaves the bucket, the "4 vs 5 o'clock" drift. Share the - // exact waypoint fn the feeder uses so the two can never diverge. + // Aim at the feeder's first waypoint (the nearest enclosing + // container boundary toward the outermost port), not the port + // directly. At depth >= 2 these differ; sharing the waypoint + // function keeps fan-out and feeder aligned. let target: { readonly x: number; readonly y: number } = hp.a; let ancestor = cluster.parent; while (ancestor) { @@ -2144,7 +1987,7 @@ export class GraphWorker { let sumY = 0; let exitCount = 0; for (const link of this.#links.linksFor(entityIdx)) { - const otherOwner = cutIndex.ownerOf(link.otherId as number); + const otherOwner = cutIndex.ownerOf(link.otherId); if ( !otherOwner || otherOwner === leafId || @@ -2161,9 +2004,8 @@ export class GraphWorker { exitCount += 1; } } - // Port-attraction target: the centroid of this entity's exits (NaN if it - // has no external connection). The entity layout's force reads this live, - // so the dots cluster near their ports instead of fanning across. + // Port-attraction target: centroid of this entity's exits + // (NaN = no external connection). if (portTargets) { const hasTarget = exitCount > 0; const nextX = hasTarget ? sumX / exitCount : Number.NaN; @@ -2177,11 +2019,7 @@ export class GraphWorker { } } - // Re-energise the (possibly settled) entity sim so the dots reach their - // ports. While the macro moves, the targets drift every tick, so track it; - // a connectivity flip is a one-off structural change. A still-running sim - // is a no-op. Once the macro settles, the warm sim relaxes onto the now- - // fixed targets, so the dots end up at their ports, not lagging the tail. + // Re-energise the entity sim so dots reach their (possibly moved) ports. if (clustersRunning || connectivityChanged) { layout.resume(); } @@ -2193,11 +2031,9 @@ export class GraphWorker { } /** - * Ports as WebCola constraints. For each opened container, add a fixed anchor - * on its rim toward each external neighbour and link the children whose edges - * cross it, so the layout sorts children toward their real connections and - * feeders leave the container without crossing. Applied only while the layout - * is still running (a settled, reused layout is left alone, no re-settle). + * Ports as WebCola constraints. For each opened container, add a fixed + * anchor on its rim toward each external neighbour and link the connected + * children. Only applied to still-running layouts. */ #applyPortConstraints(): void { const edgeFrame = this.#edgeFrame; @@ -2297,12 +2133,7 @@ export class GraphWorker { } } - /** - * Re-aim opened sub-clusters' port anchors at their (now-moved) external - * neighbours, moving the fixed anchors in place: no re-run, no structure - * emit. Light (a few anchors per opened container). Called when the macro - * layout moves; a still-running sub-cluster's children follow. - */ + /** Re-aim opened sub-clusters' port anchors at their moved external neighbours. */ #updateAnchorTracking(): void { for (const [containerId, endpointIds] of this.#anchorEndpoints) { const layout = this.#forceLayouts.get(containerId); @@ -2328,12 +2159,7 @@ export class GraphWorker { } } - /** - * Whether a reused cluster layout must be rebuilt: the child set changed, or a - * freshly-sized child now overlaps a neighbour at its frozen position (see - * {@link layoutNeedsRebuild}). The layout nodes carry the current positions; - * `child.circle.radius` is the radius just recomputed for this commit. - */ + /** Whether a reused cluster layout must be rebuilt. See {@link layoutNeedsRebuild}. */ #clusterLayoutStale(layout: LayoutSimulation, parent: ClusterNode): boolean { return layoutNeedsRebuild( layout.nodes.map((node) => ({ @@ -2360,12 +2186,8 @@ export class GraphWorker { this.#snapshotTopLevelPositions(layout); } - // Invalidate if the child set changed OR a freshly-sized child now overlaps - // a neighbour at its frozen position (a cluster that grew from 70 to 2000 - // entities, say). Harmless growth with slack around it does NOT rebuild, so - // ordinary ingest doesn't re-churn the layout; only an actual overlap does, - // and recreating re-runs WebCola's hard non-overlap with the new radii, - // warm-seeded from the persisted positions for continuity. + // Invalidate when a freshly-sized child overlaps a neighbour at its + // frozen position; harmless growth with slack around it is kept. if (layout && this.#clusterLayoutStale(layout, parent)) { this.#forceLayouts.delete(key); this.#anchorEndpoints.delete(key); @@ -2373,13 +2195,8 @@ export class GraphWorker { } if (!layout) { - // Child radii are assigned before this runs (family children by the - // bottom-up circle-packing pass (#assignRadii), subdivided type-set - // children by #layoutChildrenInParent), so a freshly-arrived child can no - // longer carry a zero/stale radius here. Top-level children re-seed from - // their persisted position when they have one (so a recreated layout, or a - // family node rebuilt as a fresh object, keeps its place); only genuinely - // new clusters fall back to the cluster-tree seed. + // Top-level children re-seed from their persisted position when + // available; genuinely new clusters fall back to the cluster-tree seed. const nodes: ForceNode[] = parent.children.map((child) => { const persisted = parent.kind === "root" @@ -2452,12 +2269,7 @@ export class GraphWorker { } } - /** - * Record the root layout's current LOCAL node positions into - * {@link #topLevelPositions} (by cluster id) so the next recreation/rebuild - * can re-seed and anchor each existing cluster to where it is now. Cheap (a - * handful of top-level nodes); called on every commit and after the optimiser. - */ + /** Snapshot the root layout's current local node positions for warm-seeding. */ #snapshotTopLevelPositions(layout: LayoutSimulation): void { for (const node of layout.nodes) { this.#topLevelPositions.set(node.id as ClusterId, { @@ -2467,13 +2279,7 @@ export class GraphWorker { } } - /** - * Authoritative world-position recomposition over the opened subtree. Replaces - * the old per-tick, clusterMoved-gated propagation: world circles are now - * recomposed before every positional read (tick and commit), so nested leaves - * never lag a moved ancestor and a commit issued while the macro is settled - * can't emit stale depth >= 2 positions. See {@link syncWorldPositions}. - */ + /** Recompose world positions over the opened subtree. See {@link syncWorldPositions}. */ #syncWorldPositions(): void { syncWorldPositions( this.#clusterTree.root, @@ -2483,11 +2289,8 @@ export class GraphWorker { } /** - * The once-per-layout settle polish: the principled optimiser for the root, - * the untangle for sub-clusters. Idempotent via {@link #untangled}, and called - * from both the tick loop (settle transition) and {@link #ensureChildrenLayout} - * (a small layout that settled inside its warm-up would otherwise be skipped by - * the tick loop forever, so its polish would never run). + * Once-per-layout settle polish: the optimiser for the root, the untangle + * for sub-clusters. Idempotent via {@link #untangled}. */ #polishSettledLayout(cluster: ClusterNode, layout: LayoutSimulation): void { if (this.#untangled.has(cluster.id)) { @@ -2502,13 +2305,9 @@ export class GraphWorker { } /** - * The principled top-level pass (root only): replace the force-settled - * positions with the layout that minimises the drawn geometry (crossings + - * detours + edge length + non-overlap + neighbour spread), all on rim-to-rim - * (port) segments (see {@link optimizeTopLevel}). The top level is unconfined, - * size-disparate, and the overview everything else inherits, so it's solved - * directly rather than via stress + a crossings polish that fight each other. - * Runs once on settle, like the untangle. + * Top-level pass (root only): replace force-settled positions with the + * layout minimising crossings, detours, edge length, non-overlap, and + * neighbour spread on rim-to-rim segments. See {@link optimizeTopLevel}. */ #optimizeTopLevelLayout( cluster: ClusterNode, @@ -2569,10 +2368,8 @@ export class GraphWorker { } /** - * D1: polish a settled cluster layout once, minimising edge crossings and - * edges-through-bubbles while staying near the force-settled seed. Only for - * small layouts (<= {@link UNTANGLE_MAX_NODES}); larger ones keep the force - * result (a stress-majorization tier could slot in here later). + * Polish a settled cluster layout once, minimising edge crossings and + * edges-through-bubbles. Only for small layouts (<= {@link UNTANGLE_MAX_NODES}). */ #untangleClusterLayout(cluster: ClusterNode, layout: LayoutSimulation): void { const edges = this.#clusterEdges.get(cluster.id); @@ -2615,7 +2412,7 @@ export class GraphWorker { this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId: key }); } - const entityIdxs = this.#collectEntityIdxsForCluster(cluster); + const entityIdxs = [...this.#entityIndicesForCluster(cluster)]; const parentR = cluster.circle.radius; const entityRadius = parentR * ENTITY_RADIUS_FRACTION; @@ -2637,9 +2434,8 @@ export class GraphWorker { const edges = this.#buildEntityEdges(entityIdxs, nodes); // Live port-attraction targets (one (x,y) per entity, NaN = no external - // connection); #buildEntityLayers fills them and the layout's force reads - // them each tick, so dots track their ports instead of fanning to a stale - // baked exit. + // connection). The layout's force reads these each tick so dots track + // their ports. const portTargets = new Float32Array(entityIdxs.length * 2).fill( Number.NaN, ); @@ -2652,9 +2448,8 @@ export class GraphWorker { ); this.#forceLayouts.set(key, layout); this.#layoutKind.set(key, "entities"); - // Per-node colours are written once here (leaf-buffer creation) and again only on a - // highlight change (via #applyHighlight) -- never per commit, which would re-write + - // re-upload every open leaf's colours on each LOD threshold crossing while zooming. + // Per-node colours are written once here and again only on highlight + // changes, not per commit (avoiding re-upload stutter while zooming). this.#writeLeafColors(cluster, layout); this.#ensureSchedulerRunning(); @@ -2668,51 +2463,42 @@ export class GraphWorker { }); } - #collectEntityIdxsForCluster(cluster: ClusterNode): EntityIndex[] { + *#entityIndicesForCluster( + cluster: ClusterNode, + ): Generator { if (cluster.membership.source === "direct") { const view = cluster.membership.members.subarray(); - const result: EntityIndex[] = []; - for (const idx of view) { - result.push(idx); - } - return result; + yield* view; + + return; } - const result: EntityIndex[] = []; + let hasEntities = false; for (const key of cluster.membership.keys) { const group = this.#typeSets.get(key); if (group) { - for (const idx of group.entities) { - result.push(idx); - } + yield* group.entities; + hasEntities ||= group.entities.length > 0; } } - // A family/rollup carries no keys of its own; its entities live in its - // children (e.g. Customer/Supplier inside the Company family). Recurse so - // those entities are attributed to it; otherwise links into the family - // (Delivery -> Customer) are dropped from the cluster-edge set, the optimiser - // never gets a Company <-> Delivery edge, and the family floats far from its - // true neighbour while the renderer still draws the (long) edge. Only when - // the node has no own entities: a subdivided type-set already covers all of - // its entities via its keys, so recursing into its partition would - // double-count. - if (result.length === 0) { + // A family/rollup carries no keys of its own; recurse into children so + // those entities are attributed to it (otherwise the optimiser misses + // edges through the family). Only when the node has no own entities: + // a subdivided type-set already covers its entities via its keys. + if (!hasEntities) { for (const child of cluster.children) { - for (const idx of this.#collectEntityIdxsForCluster(child)) { - result.push(idx); - } + yield* this.#entityIndicesForCluster(child); } } - return result; } #buildClusterEdges(children: readonly ClusterNode[]): ForceEdge[] { // Build entityIdx -> childId lookup once. - const entityToChild = new Map(); - const childEntityIdxs = new Map(); + const entityToChild = new Map(); + const childEntityIdxs = new Map(); for (const child of children) { - const entityIdxs = this.#collectEntityIdxsForCluster(child); + const entityIdxs = [...this.#entityIndicesForCluster(child)]; childEntityIdxs.set(child.id, entityIdxs); for (const entityIdx of entityIdxs) { entityToChild.set(entityIdx, child.id); @@ -2754,8 +2540,8 @@ export class GraphWorker { entityIdxs: EntityIndex[], nodes: ForceNode[], ): ForceEdge[] { - const memberSet = new Set(entityIdxs); - const idxToNodeId = new Map(); + const memberSet = new Set(entityIdxs); + const idxToNodeId = new Map(); for (let idx = 0; idx < entityIdxs.length; idx++) { idxToNodeId.set(entityIdxs[idx]!, nodes[idx]!.id); } @@ -2829,11 +2615,10 @@ export class GraphWorker { }); } - // Name the grouping fallback (link-signature `community` + `entity-bucket` chunks) by the - // same distinctive-feature machinery, so groups carry a meaningful name BEFORE (or without) - // embeddings. Type-set children are excluded -- the type labeler already names those; random - // chunks self-filter (no feature is distinctive of a random slice, so they keep `Group n`). - // If embeddings later arrive they REPLACE these children and re-name via applyEmbeddingResult. + // Name fallback groups (community + entity-bucket) by distinctive features so + // they carry a meaningful name before (or without) embeddings. Type-set children + // are excluded (the type labeler names those). If embeddings arrive later they + // replace these children and re-name via applyEmbeddingResult. const fallbackGroups: ClusterMembers[] = []; for (const child of node.children) { if ( @@ -2919,17 +2704,11 @@ export class GraphWorker { } /** - * Schedule distinctive-feature naming over a freshly-created set of SIBLING child clusters -- - * embedding (kmeans) groups OR the non-embedding grouping fallback (link-signature `community` - * buckets + `entity-bucket` chunks), which render with a `Group n`/`Similar group n` - * placeholder. Names from a UNIFIED feature space -- exact `property = value`, numeric/date - * ranges, and link/target-type ("what they link to") -- so groups distinguished by magnitude - * or by their edges get named, not just those with a distinctive categorical value. Deferred - * onto the job scheduler because the scan is O(members x features): the placeholder commit - * paints first, then the relabel + recommit lands once it completes. Both paths share this so - * they name identically; it deliberately does NOT touch the type-set (`distinctiveLabel`) or - * community (`labelAllCommunities`) labelers -- the namer only sets text where it finds a - * confident, distinctive signature, leaving every other group's label intact. + * Schedule distinctive-feature naming for sibling child clusters (embedding + * groups or fallback buckets). Names from a unified feature space: exact + * property values, numeric/date ranges, and link/target types. Deferred onto + * the job scheduler (O(members x features)); the placeholder commit paints + * first, then the relabel lands once the scan completes. */ #scheduleDistinctiveFeatureNaming(groups: readonly ClusterMembers[]): void { if (groups.length === 0) { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts index aba772cc571..fd550f054f5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts @@ -1,27 +1,21 @@ /** - * When a settled cluster layout may be REUSED versus rebuilt from scratch. + * Decides when a settled cluster layout must be rebuilt. * - * A layout is keyed by its parent and reused across commits for stability (so - * the arrangement doesn't churn on every ingest). But a reused layout keeps the - * positions it was solved with, while child radii are recomputed every commit - * (radius ~ sqrt(entity count)). So a child that grows can end up OVERLAPPING a - * neighbour at its frozen position — and, since the child COUNT is unchanged, a - * count-only reuse guard never notices and the overlap is drawn forever. + * Layouts are reused across commits for stability, but a reused layout keeps + * frozen positions while child radii grow with entity count. A child that + * grows can overlap a neighbour at its frozen position, and a count-only + * guard never notices. * - * The trigger is the overlap ITSELF, not a proxy like "radius grew > X%": - * - growth with slack around it (no overlap) does NOT rebuild → no churn; - * - growth INTO a neighbour rebuilds → the overlap is re-solved; - * - a shrink never rebuilds (it only frees space). - * A percentage-of-radius threshold gets both wrong: it rebuilds harmless growth - * (churn) yet misses growth that overlaps in a tightly-packed spot. + * The trigger is the overlap itself: growth with slack around it keeps the + * layout, growth into a neighbour rebuilds it, and a shrink never rebuilds + * (it only frees space). A radius-percentage threshold would rebuild + * harmless growth (churn) yet miss overlap in a tightly-packed spot. */ /** * How far one bubble may penetrate another (as a fraction of the smaller - * radius) before a rebuild is forced. A small dead-band: it stops a freshly - * solved layout — which leaves a little padding between bubbles — from - * rebuilding the instant a child nibbles into that padding, while still firing - * on any real, visible overlap. + * radius) before a rebuild is forced. A small dead-band so freshly-solved + * padding doesn't trigger an immediate rebuild. */ export const OVERLAP_REBUILD_TOLERANCE_FRAC = 0.05; @@ -37,11 +31,8 @@ interface SizedNode { } /** - * Whether a reused layout (`previous`, the live layout nodes with their current - * positions) must be rebuilt to fit the `current` children (same ids, but - * freshly-sized radii): the child set changed, or some child now overlaps a - * neighbour by more than `toleranceFrac` of the smaller radius. Pure and - * side-effect free so it can be unit-tested directly. + * Whether a reused layout must be rebuilt: the child set changed, or a child + * now overlaps a neighbour by more than `toleranceFrac` of the smaller radius. */ export function layoutNeedsRebuild( previous: readonly PlacedNode[], diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts index d14d2530098..9820a015702 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/viewport-anchor.ts @@ -1,22 +1,16 @@ /** - * How strongly a top-level bubble resists moving during an incremental refine, - * derived from where it sits on the user's screen. + * How strongly a top-level bubble resists moving during an incremental refine. * - * Two ideas combine: - * - CENTRALITY: pin what the user is looking at, let the rest reflow. The weight - * is ~1 at the viewport centre and decays (Gaussian) to {@link - * VIEWPORT_ANCHOR_FLOOR} once a bubble is roughly off-screen. The falloff - * radius is the viewport's visible half-diagonal in WORLD units, so it scales - * with zoom: zoomed in, only the few central bubbles are held; zoomed out, - * most of the graph is. - * - ZOOM (screen-space stability): the inertia is penalised in WORLD units, but - * the user perceives SCREEN units, and `scale = 2**zoom` is screen px per world - * unit. So a wobble that's negligible in the world is magnified by `scale` when - * zoomed in — a bubble you've zoomed right into visibly drifts even though it - * barely moved. We amplify the on-screen pin by `scale` (clamped to ≥ 1× so - * zooming OUT keeps the baseline) to hold the focused bubble's SCREEN movement - * roughly constant across zoom. The amplification multiplies only the centrality - * term, so off-screen bubbles stay at the floor and remain free to reflow. + * Centrality: weight is ~1 at viewport centre, decaying (Gaussian) to + * {@link VIEWPORT_ANCHOR_FLOOR} once a bubble is roughly off-screen. The + * falloff radius is the visible half-diagonal in world units, so it scales + * with zoom. + * + * Zoom amplification: inertia is penalised in world units, but the user + * perceives screen units. A world-space wobble is magnified by `scale` + * when zoomed in. The on-screen pin is amplified by `scale` (clamped >= 1 + * so zooming out keeps the baseline), applied only to the centrality term + * so off-screen bubbles remain free to reflow. */ import type { ViewportState } from "../hierarchy/lod"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts index cf19e06fe8c..38cd75d884a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/bubble-ports.ts @@ -1,27 +1,23 @@ /* eslint-disable id-length, no-param-reassign */ /** - * Bubble ports: dedicated boundary points where edges enter or exit - * a cluster. For each pair of connected visible clusters, a port on - * each cluster faces the other. All edges between the pair are routed - * through these two ports, producing clean bundled paths. + * Bubble ports: dedicated boundary points where edges enter or exit a cluster. * - * Port slotting ensures minimum angular separation between ports on - * the same cluster. When too many neighbors lie in similar directions, - * ports are merged into angular sectors. - * - * Hysteresis: port assignments are cached per (clusterId, neighbor set). - * Ports only recompute when the set of connected neighbors changes. + * For each pair of connected visible clusters, a port on each cluster faces + * the other. Minimum angular separation is enforced; when too many neighbors + * lie in similar directions, ports merge into angular sectors. Port assignments + * are cached and only recompute when the connected neighbor set changes. */ + import type { VizConfig } from "../../config"; import type { Circle } from "../../geometry"; import type { ClusterId } from "../../ids"; import type { ClusterTree } from "../hierarchy/cluster-tree"; -/** Minimal interface for pair data needed by port computation. */ + export interface PairInfo { readonly sourceId: ClusterId; readonly targetId: ClusterId; readonly totalCount: number; - readonly byType: ReadonlyMap; + readonly byType: ReadonlySet; } export interface Port { @@ -49,8 +45,6 @@ interface RawPort { readonly distinctTypes: number; } -// Helpers (above callers) - function makePort( clusterId: ClusterId, neighborId: ClusterId, @@ -78,11 +72,9 @@ function makePort( const MAX_PORT_ARC = Math.PI / 3; /** - * Pool Adjacent Violators: the optimal monotone non-decreasing least-squares fit - * of `desired`, in O(n). With the cumulative reserved-arc offsets folded out - * (see {@link placePortsOnPerimeter}), placing ports at minimum total - * displacement subject to "stay in cyclic order, keep a gap apart" is isotonic - * regression, and PAVA solves it exactly. + * Pool Adjacent Violators (PAVA): optimal monotone non-decreasing least-squares + * fit of `desired`, in O(n). See {@link placePortsOnPerimeter} for how the + * isotonic regression maps to port placement. */ function poolAdjacentViolators(desired: readonly number[]): number[] { const blocks: { sum: number; count: number }[] = []; @@ -110,20 +102,13 @@ function poolAdjacentViolators(desired: readonly number[]): number[] { } /** - * Place ports on the bubble's perimeter. Model: a port is a node constrained to - * the circle, free to slide along it. Each wants its ideal angle, the perimeter - * point nearest its target (straight toward the neighbor), which keeps the - * leader short and stops the edge cutting back through the bubble. Ports reserve - * an arc (proportional to lane count, so wider bundles get more room) and may - * not overlap; we slide them the minimum total amount to satisfy that while - * preserving their cyclic order, so leaders never cross. + * Place ports on the bubble's perimeter with minimum displacement, preserving + * cyclic order and enforcing arc-based non-overlap. * - * Theory: fix the cyclic order (cut the rim at the pair with the most free - * space) and fold out the cumulative reserved-arc offsets, then "minimise - * sum(placed - ideal)² s.t. placed stays ordered, >= a gap apart" is exactly - * isotonic regression, solved optimally by PAVA. It is the 1-D specialisation of - * the separation-constraint solve (VPSC) WebCola runs for node non-overlap, the - * same theory, applied to angles on the rim. + * Each port wants its ideal angle (straight toward its neighbor). Ports reserve + * an arc proportional to lane count and may not overlap. The cycle is cut at the + * pair with the most slack, then PAVA solves the resulting 1-D isotonic problem + * exactly. * * `ports` is pre-sorted ascending by ideal angle; mutates `port.angle`. */ @@ -205,10 +190,7 @@ function placePortsOnPerimeter( } } -/** - * Merge ports into angular sectors when there are too many for the - * available screen space. Each neighbor maps to its sector's merged port. - */ +/** Merge ports into angular sectors when there are too many for the rim. */ function mergeByAngularSector( ports: readonly RawPort[], sectorCount: number, @@ -275,10 +257,7 @@ function mergeByAngularSector( return result; } -/** - * Compute slotted ports for a single cluster, returning a map - * from each neighbor cluster ID to the port serving it. - */ +/** Compute slotted ports for a single cluster. */ function slotPorts( clusterId: ClusterId, rawPorts: RawPort[], @@ -292,16 +271,12 @@ function slotPorts( // Sort by angle for slotting. rawPorts.sort((a, b) => a.angle - b.angle); - // Zoom-independent slotting: a port's slot is a function of neighbor - // direction only, so ports never re-slot or reassign as the user pans/zooms - // (spec section 6.5.3 hysteresis). They recompute only when the neighbor set - // or cluster positions change, which the PortCache key captures. + // Slotting is zoom-independent: ports recompute only when neighbor set + // or cluster positions change, not on pan/zoom. const portCap = config.maxPortsPerCluster; const minSepAngle = (2 * Math.PI) / portCap; if (rawPorts.length <= portCap) { - // Slide ports to their minimum-displacement, non-overlapping, order- - // preserving positions on the rim (ideal = straight toward each neighbor). placePortsOnPerimeter(rawPorts, minSepAngle, MAX_PORT_ARC); const result = new Map(); @@ -333,16 +308,10 @@ function slotPorts( ); } -// Port hysteresis cache - /** - * Caches port assignments per cluster, keyed by a composite signature - * that captures everything port positions depend on: the cluster's own - * circle, each neighbor's center, the neighbor set, and zoom. - * - * Ports are reused only when none of those inputs changed, preserving - * hysteresis without freezing ports at stale positions as the force - * layout moves clusters. + * Caches port assignments per cluster, keyed by a signature of the + * cluster's circle, each neighbor's center, and the neighbor set. + * Ports reuse cached positions until an input changes. */ export class PortCache { readonly #cache = new Map< @@ -367,8 +336,6 @@ export class PortCache { } } -// Top-level port computation - interface NeighborInfo { readonly neighborId: ClusterId; readonly edgeCount: number; @@ -390,13 +357,7 @@ function addNeighborInfo( list.push({ neighborId: to, edgeCount, distinctTypes }); } -/** - * Compute ports for all connected cluster pairs. Returns a map - * from pair key to { source port, target port }. - * - * Uses the PortCache for hysteresis: unchanged neighbor sets - * reuse cached port positions. - */ +/** Compute ports for all connected cluster pairs. */ export function computeAllPorts( pairs: ReadonlyMap, clusterTree: ClusterTree, @@ -456,9 +417,6 @@ export function computeAllPorts( } sigParts.sort(); - // No zoom in the key: slotting is zoom-independent, so a pan/zoom never - // invalidates ports. Positions stay in the key so ports follow the layout - // while it settles, then stay put once it freezes. const cacheKey = `${cluster.circle.x.toFixed(2)},${cluster.circle.y.toFixed(2)},${cluster.circle.radius.toFixed(2)}|${sigParts.join( ";", )}`; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts index 8590f048ca5..888098e97f4 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-aggregation.ts @@ -11,7 +11,7 @@ * aggregate / individual / hidden (no double counting). * 2. For every aggregate edge (A, B, type), its count equals * the number of links whose endpoint visible owners are A and B - * with that TypeSetIdx. + * with that TypeSetId. * 3. Visual keys are based on semantic identity (cluster pair + type), * not array order. * 4. Even when rendering a collapsed edge, exact byType counts are kept. @@ -24,7 +24,7 @@ import { edgeColorForType, primaryTypeOfSet } from "../entity-style"; import type { VizConfig } from "../../config"; import type { Color } from "../../frames"; -import type { ClusterId, EntityIndex, TypeSetId } from "../../ids"; +import type { ClusterId, EntityIndex, LinkId, TypeSetId } from "../../ids"; import type { ClusterNode, ClusterTree } from "../hierarchy/cluster-tree"; import type { LodItem } from "../hierarchy/lod"; import type { LinkStore } from "../store/link"; @@ -32,8 +32,6 @@ import type { TypeRegistry } from "../store/type-registry"; import type { TypeSetGroup, TypeSetStore } from "../store/type-set"; import type { VersionedUrl } from "@blockprotocol/type-system"; -// Color / width / label helpers - export function widthForCount(count: number): number { return Math.max(1, Math.min(8, 1 + Math.log2(count + 1))); } @@ -54,14 +52,13 @@ export function typeLabelForGroup( } } - return titles.length > 0 ? titles.join(" \u00d7 ") : "Unknown"; + return titles.length > 0 ? titles.join(" × ") : "Unknown"; } /** - * The single link type's VersionedUrl for a type-set group, or `null` if the group covers more - * than one type (a rollup). A lane is single-type by definition, so its group has one direct type; - * shipping its URL lets the main thread resolve the icon/title from the closed type schema it - * already holds, without the worker shipping any rich type data. + * The single link type's VersionedUrl for a type-set group, or `null` if the + * group covers more than one type (a rollup). The main thread resolves the + * type's icon and title from the closed type schema it already holds. */ export function typeUrlForGroup( group: TypeSetGroup | undefined, @@ -70,26 +67,31 @@ export function typeUrlForGroup( if (!group) { return null; } + let only: VersionedUrl | null = null; let seen = 0; + for (const typeIdx of group.directTypeIds) { const url = types.getUrl(typeIdx); if (url === undefined) { continue; } + seen += 1; if (seen > 1) { return null; } + only = url; } + return only; } /** - * The reverse (target -> source) label for a type-set group: each type's inverse title ("Member - * Of") in place of its forward title ("Has Member"), so a reverse lane reads correctly. Falls back - * to the forward title for a type with no inverse. + * The reverse (target -> source) label for a type-set group. Each type's + * inverse title ("Member Of") replaces its forward title ("Has Member"); + * falls back to the forward title for types with no inverse. */ export function inverseLabelForGroup( group: TypeSetGroup | undefined, @@ -102,6 +104,7 @@ export function inverseLabelForGroup( const titles: string[] = []; for (const typeIdx of group.directTypeIds) { const info = types.get(typeIdx); + if (info) { titles.push(info.inverseTitle ?? info.title); } @@ -110,8 +113,6 @@ export function inverseLabelForGroup( return titles.length > 0 ? titles.join(" × ") : "Unknown"; } -// Cut index - function collectEntityOwnership( node: ClusterNode, typeSets: TypeSetStore, @@ -120,14 +121,14 @@ function collectEntityOwnership( ): void { if (node.membership.source === "direct") { for (const idx of node.membership.members.subarray()) { - result.set(idx as number, ownerId); + result.set(idx, ownerId); } } else { for (const key of node.membership.keys) { const group = typeSets.get(key); if (group) { for (const idx of group.entities) { - result.set(idx as number, ownerId); + result.set(idx, ownerId); } } } @@ -144,15 +145,14 @@ function collectEntityOwnership( /** * Maps every entity to its owning cluster at the current LOD level. * - * Built from all clusters in the tree (not viewport-filtered). - * The viewport LOD cut determines which clusters are "open" (showing - * children or entities). Off-screen clusters default to "cluster" mode. - * Frustum culling is left to the presentation layer (Deck.gl). + * Built from all clusters in the tree (not viewport-filtered); + * off-screen clusters default to "cluster" mode. Frustum culling + * is left to Deck.gl. */ export class CutIndex { readonly #entityModeIds: ReadonlySet; readonly #containerIds: ReadonlySet; - readonly #entityOwner: ReadonlyMap; + readonly #entityOwner: ReadonlyMap; constructor( viewportCut: readonly LodItem[], @@ -162,7 +162,7 @@ export class CutIndex { const blockIds = new Set(); const entityModeIds = new Set(); const containerIds = new Set(); - const entityOwner = new Map(); + const entityOwner = new Map(); // Index viewport cut by cluster ID for mode lookup. const cutModes = new Map(); @@ -187,17 +187,10 @@ export class CutIndex { this.#entityOwner = entityOwner; } - /** Clusters in "children" mode (showing sub-clusters). */ get containerIds(): ReadonlySet { return this.#containerIds; } - /** - * Recursively walk the cluster tree. "children" mode means - * the node is a container: recurse into children. Any other mode - * (or absent from cut) means this node is a block that owns - * all its entities. - */ #walkTree( node: ClusterNode, cutModes: ReadonlyMap, @@ -255,7 +248,7 @@ export class CutIndex { return this.#entityOwner.size; } - ownerOf(entityIdx: number): ClusterId | undefined { + ownerOf(entityIdx: EntityIndex): ClusterId | undefined { return this.#entityOwner.get(entityIdx); } @@ -267,14 +260,11 @@ export class CutIndex { return this.#entityModeIds; } - /** Iterate all (entityIdx, ownerId) pairs for incremental diffing. */ - *entries(): IterableIterator<[number, ClusterId]> { + *entries(): IterableIterator<[EntityIndex, ClusterId]> { yield* this.#entityOwner; } } -// Pair key - const SEP = "\u001f"; export function makePairKey( @@ -291,44 +281,27 @@ export function makePairKey( return { key: PairKey(`${a}${SEP}${b}`), sourceId: a, targetId: b }; } -// Internal mutable aggregation types - interface TypeAggregation { readonly typeSetId: TypeSetId; - /** - * Links flowing from the lower-sorted cluster ID to the higher one, - * matching PairKey's sort order. - */ - forwardCount: number; - /** Links flowing in the opposite direction. */ - reverseCount: number; - /** - * The link entities counted in `forwardCount`. Maintained in lockstep with - * the count (added on +1, removed on -1), so a clicked highway lane can - * resolve, on demand, to the exact set of links it aggregates. - */ - readonly forwardLinks: Set; - /** The link entities counted in `reverseCount` (mirror of `forwardLinks`). */ - readonly reverseLinks: Set; + readonly forward: Set; + readonly reverse: Set; } interface MutablePairAggregation { readonly sourceId: ClusterId; readonly targetId: ClusterId; - readonly byType: Map; + readonly byType: Map; totalCount: number; } interface StoredIndividualEdge { - readonly linkId: number; + readonly linkId: LinkId; readonly ownerClusterId: ClusterId; - readonly leftEntityIdx: number; - readonly rightEntityIdx: number; + readonly leftEntityIndex: EntityIndex; + readonly rightEntityIndex: EntityIndex; readonly typeSetId: TypeSetId; } -// Visual edge types (spec 6.2) - interface ClusterEndpointRef { readonly kind: "cluster"; readonly id: ClusterId; @@ -336,7 +309,7 @@ interface ClusterEndpointRef { interface EntityEndpointRef { readonly kind: "entity"; - readonly entityIdx: number; + readonly entityIdx: EntityIndex; readonly ownerClusterId: ClusterId; } @@ -381,7 +354,7 @@ export interface AggregatedVisualEdge { * forward links, a reverse lane the reverse, a collapsed/"both" lane the * union of both. Lets a clicked highway resolve to its underlying links. */ - readonly linkEntityIdxs: readonly EntityIndex[]; + readonly entities: Set; } export interface IndividualVisualEdge { @@ -389,7 +362,7 @@ export interface IndividualVisualEdge { readonly visualKey: VisualEdgeKey; readonly source: EntityEndpointRef; readonly target: EntityEndpointRef; - readonly linkId: number; + readonly linkId: LinkId; readonly typeSetId: TypeSetId; readonly count: 1; readonly color: Color; @@ -407,8 +380,6 @@ export interface EdgeFrame { readonly truncated: boolean; } -// Pair explosion: convert raw aggregation into visual edges - function explodePair( pairKey: PairKey, pair: MutablePairAggregation, @@ -416,21 +387,25 @@ function explodePair( types: TypeRegistry, config: VizConfig, ): AggregatedVisualEdge[] { - const typeAggs = [...pair.byType.values()].sort( + const aggregations = [...pair.byType.values()].sort( (a, b) => - b.forwardCount + b.reverseCount - (a.forwardCount + a.reverseCount) || - (a.typeSetId as number) - (b.typeSetId as number), + b.forward.size + b.reverse.size - (a.forward.size + a.reverse.size) || + a.typeSetId - b.typeSetId, ); const source: ClusterEndpointRef = { kind: "cluster", id: pair.sourceId }; const target: ClusterEndpointRef = { kind: "cluster", id: pair.targetId }; - if (typeAggs.length > config.maxParallelEdgeTypes) { + if (aggregations.length > config.maxParallelEdgeTypes) { // A collapsed/"both" lane carries the union of both directions' links. - const collapsedLinks: EntityIndex[] = []; - for (const agg of typeAggs) { - collapsedLinks.push(...agg.forwardLinks, ...agg.reverseLinks); + let collapsedLinks: Set = new Set(); + + for (const aggregation of aggregations) { + collapsedLinks = collapsedLinks + .union(aggregation.forward) + .union(aggregation.reverse); } + return [ { kind: "aggregate", @@ -444,13 +419,13 @@ function explodePair( collapsed: true, count: pair.totalCount, totalPairCount: pair.totalCount, - distinctTypeCount: typeAggs.length, - color: [...graphColors.collapsedEdge], + distinctTypeCount: aggregations.length, + color: graphColors.collapsedEdge, widthWorld: widthForCount(pair.totalCount), - typeLabel: `${typeAggs.length} link types`, + typeLabel: `${aggregations.length} link types`, // Assigned once the final visualEdges order is known (#buildFrame). laneId: -1, - linkEntityIdxs: collapsedLinks, + entities: collapsedLinks, }, ]; } @@ -458,63 +433,66 @@ function explodePair( // A type with links in both directions becomes two separate lanes // (one per direction), each sized by its own count. const edges: AggregatedVisualEdge[] = []; - for (const agg of typeAggs) { - const group = typeSets.getById(agg.typeSetId); + + for (const aggregate of aggregations) { + const group = typeSets.getById(aggregate.typeSetId); + const typeLabel = typeLabelForGroup(group, types); const inverseLabel = inverseLabelForGroup(group, types); const typeId = typeUrlForGroup(group, types); + const color = edgeColorForType( group ? primaryTypeOfSet(group.directTypeIds, types) : undefined, types, ); - if (agg.forwardCount > 0) { + if (aggregate.forward.size > 0) { edges.push({ kind: "aggregate", visualKey: VisualEdgeKey( - `agg:${pairKey}:${agg.typeSetId as number}:forward`, + `agg:${pairKey}:${aggregate.typeSetId as number}:forward`, ), source, target, pairKey, - typeSetId: agg.typeSetId, + typeSetId: aggregate.typeSetId, typeId, direction: "forward", collapsed: false, - count: agg.forwardCount, + count: aggregate.forward.size, totalPairCount: pair.totalCount, - distinctTypeCount: typeAggs.length, + distinctTypeCount: aggregations.length, color, - widthWorld: widthForCount(agg.forwardCount), + widthWorld: widthForCount(aggregate.forward.size), typeLabel, // Assigned once the final visualEdges order is known (#buildFrame). laneId: -1, - linkEntityIdxs: Array.from(agg.forwardLinks), + entities: structuredClone(aggregate.forward), }); } - if (agg.reverseCount > 0) { + if (aggregate.reverse.size > 0) { edges.push({ kind: "aggregate", visualKey: VisualEdgeKey( - `agg:${pairKey}:${agg.typeSetId as number}:reverse`, + `agg:${pairKey}:${aggregate.typeSetId as number}:reverse`, ), source, target, pairKey, - typeSetId: agg.typeSetId, + typeSetId: aggregate.typeSetId, typeId, direction: "reverse", collapsed: false, - count: agg.reverseCount, + count: aggregate.reverse.size, totalPairCount: pair.totalCount, - distinctTypeCount: typeAggs.length, + distinctTypeCount: aggregations.length, color, - widthWorld: widthForCount(agg.reverseCount), + widthWorld: widthForCount(aggregate.reverse.size), typeLabel: inverseLabel, // Assigned once the final visualEdges order is known (#buildFrame). laneId: -1, - linkEntityIdxs: Array.from(agg.reverseLinks), + entities: structuredClone(aggregate.reverse), }); } } @@ -522,15 +500,10 @@ function explodePair( return edges; } -// Edge aggregator - /** - * Maintains edge aggregation state across frames. Supports - * incremental updates: when the LOD cut changes, only links - * whose visible owner changed are reclassified. - * - * Falls back to full recomputation when >35% of entities - * changed owners, or on first run / after reset. + * Maintains edge aggregation state across frames. When the LOD cut changes, + * only links whose visible owner changed are reclassified; falls back to + * full recomputation when >35% of entities changed owners. */ export class EdgeAggregator { readonly #pairs = new Map(); @@ -545,10 +518,7 @@ export class EdgeAggregator { this.#previousCutIndex = undefined; } - /** - * Update aggregation for a new LOD cut. Uses incremental - * reclassification when possible, full recomputation otherwise. - */ + /** Update aggregation for a new LOD cut. */ update( cutIndex: CutIndex, linkStore: LinkStore, @@ -579,26 +549,23 @@ export class EdgeAggregator { return this.#pairs; } - /** - * Classify a link and apply its contribution (sign = +1) - * or undo it (sign = -1). - */ + /** Apply (sign = +1) or undo (sign = -1) a link's aggregation contribution. */ #applyLink( - linkId: number, - leftIdx: number, - rightIdx: number, + linkId: LinkId, + leftIndex: EntityIndex | -1, + rightIndex: EntityIndex | -1, typeSetId: TypeSetId, linkEntityIdx: EntityIndex, cutIndex: CutIndex, sign: 1 | -1, ): void { - if (leftIdx === -1 || rightIdx === -1) { + if (leftIndex === -1 || rightIndex === -1) { this.#hiddenCount += sign; return; } - const leftOwner = cutIndex.ownerOf(leftIdx); - const rightOwner = cutIndex.ownerOf(rightIdx); + const leftOwner = cutIndex.ownerOf(leftIndex); + const rightOwner = cutIndex.ownerOf(rightIndex); if (!leftOwner || !rightOwner) { this.#hiddenCount += sign; @@ -612,8 +579,8 @@ export class EdgeAggregator { this.#individuals.set(linkId, { linkId, ownerClusterId: leftOwner, - leftEntityIdx: leftIdx, - rightEntityIdx: rightIdx, + leftEntityIndex: leftIndex, + rightEntityIndex: rightIndex, typeSetId, }); } else { @@ -628,9 +595,7 @@ export class EdgeAggregator { // Different visible owners: aggregate. const { key, sourceId, targetId } = makePairKey(leftOwner, rightOwner); - // The link flows from leftOwner (source) to rightOwner (target). - // "forward" means that flow matches the PairKey sort order, i.e. - // the link's source owner is the lower-sorted cluster. + // "forward" = flow matches PairKey sort order (source is the lower-sorted cluster). const forward = leftOwner === sourceId; if (sign === 1) { @@ -640,46 +605,49 @@ export class EdgeAggregator { this.#pairs.set(key, pair); } - let typeAgg = pair.byType.get(typeSetId as number); - if (!typeAgg) { - typeAgg = { + let aggregation = pair.byType.get(typeSetId); + + if (!aggregation) { + aggregation = { typeSetId, - forwardCount: 0, - reverseCount: 0, - forwardLinks: new Set(), - reverseLinks: new Set(), + forward: new Set(), + reverse: new Set(), }; - pair.byType.set(typeSetId as number, typeAgg); + + pair.byType.set(typeSetId, aggregation); } + if (forward) { - typeAgg.forwardCount++; - typeAgg.forwardLinks.add(linkEntityIdx); + aggregation.forward.add(linkEntityIdx); } else { - typeAgg.reverseCount++; - typeAgg.reverseLinks.add(linkEntityIdx); + aggregation.reverse.add(linkEntityIdx); } - pair.totalCount++; - } else { - const pair = this.#pairs.get(key); - if (pair) { - const typeAgg = pair.byType.get(typeSetId as number); - if (typeAgg) { - if (forward) { - typeAgg.forwardCount--; - typeAgg.forwardLinks.delete(linkEntityIdx); - } else { - typeAgg.reverseCount--; - typeAgg.reverseLinks.delete(linkEntityIdx); - } - if (typeAgg.forwardCount <= 0 && typeAgg.reverseCount <= 0) { - pair.byType.delete(typeSetId as number); - } - } - pair.totalCount--; - if (pair.totalCount <= 0) { - this.#pairs.delete(key); - } + + pair.totalCount += 1; + return; + } + + const pair = this.#pairs.get(key); + if (pair === undefined) { + return; + } + + const aggregation = pair.byType.get(typeSetId); + if (aggregation) { + if (forward) { + aggregation.forward.delete(linkEntityIdx); + } else { + aggregation.reverse.delete(linkEntityIdx); } + + if (aggregation.forward.size === 0 && aggregation.reverse.size === 0) { + pair.byType.delete(typeSetId); + } + } + + pair.totalCount -= 1; + if (pair.totalCount <= 0) { + this.#pairs.delete(key); } } @@ -688,13 +656,13 @@ export class EdgeAggregator { this.#individuals.clear(); this.#hiddenCount = 0; - for (let i = 0; i < linkStore.count; i++) { + for (let link = 0; link < linkStore.count; link++) { this.#applyLink( - i, - linkStore.getLeft(i) as number, - linkStore.getRight(i) as number, - linkStore.getTypeSetId(i), - linkStore.getEntityIndex(i), + link as LinkId, + linkStore.getLeft(link), + linkStore.getRight(link), + linkStore.getTypeSetId(link), + linkStore.getEntityIndex(link), cutIndex, 1, ); @@ -702,23 +670,23 @@ export class EdgeAggregator { } #incrementalUpdate( - changedEntities: Set, + changedEntities: Set, newCutIndex: CutIndex, linkStore: LinkStore, ): void { const oldCutIndex = this.#previousCutIndex!; - const processedLinks = new Set(); + const processedLinks = new Set(); for (const entityIdx of changedEntities) { - const links = linkStore.linksFor(entityIdx as EntityIndex); - for (const link of links) { + for (const link of linkStore.linksFor(entityIdx)) { if (processedLinks.has(link.linkId)) { continue; } + processedLinks.add(link.linkId); - const leftIdx = linkStore.getLeft(link.linkId) as number; - const rightIdx = linkStore.getRight(link.linkId) as number; + const leftIdx = linkStore.getLeft(link.linkId); + const rightIdx = linkStore.getRight(link.linkId); const typeSetId = linkStore.getTypeSetId(link.linkId); const linkEntityIdx = linkStore.getEntityIndex(link.linkId); @@ -732,6 +700,7 @@ export class EdgeAggregator { oldCutIndex, -1, ); + this.#applyLink( link.linkId, leftIdx, @@ -745,8 +714,8 @@ export class EdgeAggregator { } } - #findChangedEntities(newCutIndex: CutIndex): Set { - const changed = new Set(); + #findChangedEntities(newCutIndex: CutIndex): Set { + const changed = new Set(); const oldCutIndex = this.#previousCutIndex!; // Entities whose owner changed or who left the view. @@ -802,12 +771,12 @@ export class EdgeAggregator { visualKey: VisualEdgeKey(`link:${edge.linkId}`), source: { kind: "entity", - entityIdx: edge.leftEntityIdx, + entityIdx: edge.leftEntityIndex, ownerClusterId: edge.ownerClusterId, }, target: { kind: "entity", - entityIdx: edge.rightEntityIdx, + entityIdx: edge.rightEntityIndex, ownerClusterId: edge.ownerClusterId, }, linkId: edge.linkId, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts index c7a0c3750ee..6b832a41029 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/edge-geometry.ts @@ -57,8 +57,6 @@ interface CubicCurve { readonly p3: readonly [number, number]; } -// Flat-array segment accumulator - const FLOATS_PER_SEGMENT = 8; const BYTES_PER_COLOR = 4; /** Two clip circles per segment: `(cx, cy, signedRadius)` x 2 (one per end). */ @@ -314,8 +312,6 @@ function cubicBetweenWaypoints( }; } -// Container hierarchy analysis - interface ContainerCrossing { readonly containerId: ClusterId; readonly circle: Circle; @@ -393,8 +389,6 @@ export function containerBoundaryWaypoint( }; } -// Merged type info for highways that combine multiple children's edges. - /** * Direction of a highway/feeder lane, normalized so that "forward" * always means flow from the highway source side to the target side, @@ -479,8 +473,6 @@ function mergeLanes( })); } -// Recursive feeders: child -> intermediate containers -> outermost port - interface FeederTypeInfo { count: number; color: Color; @@ -528,8 +520,6 @@ function mergeFeederTypes( } } -// Aggregate edge path building - interface ClassifiedPair { readonly pairKey: PairKey; readonly edges: AggregatedVisualEdge[]; @@ -560,8 +550,6 @@ interface HighwayGroup { readonly targetChildren: HighwayGroupChild[]; } -// Main entry point - export interface EdgeGeometryContext { readonly clusterTree: ClusterTree; readonly cutIndex: CutIndex; @@ -577,8 +565,6 @@ const ROUTE_CLEARANCE_MUL = 1.15; /** Ignore obstacles this close (fraction of chord) to either endpoint. */ const ROUTE_END_MARGIN = 0.08; -// Bezier segment output (for BezierSDFLayer) - /** * Compute raw cubic Bezier curves between consecutive waypoints. * Returns one CubicCurve per segment (no tessellation). diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts index d16d80faf70..a4d0b4e87cb 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/geometry/world-positions.ts @@ -1,25 +1,12 @@ /** - * Authoritative world-position composition for the opened cluster subtree. + * Recompose world positions for the opened cluster subtree. * - * A nested cluster's world position is a pure function of the layout offsets - * down its ancestor chain: `child.world = parent.world + child.localOffset`, - * where `localOffset` is the child's position in the parent's (local-frame) - * layout. The macro layout moves only the top-level clusters; everything deeper - * inherits that movement through this composition. + * `child.world = parent.world + child.localOffset`. Must run even through + * settled layouts: the macro layout may have moved an intermediate container, + * and a settled child layout never re-publishes its world circles. * - * Crucially this must run even through layouts that have already settled: at - * depth >= 2, an intermediate container's own layout is fine (its children's - * local offsets are stable) but its world position shifted because the macro - * moved it, and a settled layout never ticks to re-publish its children's world - * circles. So instead of relying on each layout to write its own children when - * it happens to tick (which left depth >= 2 leaves, and the ports the entity dots - * chase, frozen at the parent's old position), we recompose the whole opened - * subtree top-down before any positional read. - * - * Top-down order matters: a parent's world circle is updated before we descend, - * so each level reads an already-correct parent. Recursion stops where there is - * no child cluster layout (closed clusters, entity leaves), so the cost is - * bounded by the opened subtree, not the whole tree. + * Top-down order: a parent's world circle is updated before we descend. + * Cost is bounded by the opened subtree, not the whole tree. */ import type { ClusterId } from "../../ids"; import type { ClusterNode } from "../hierarchy/cluster-tree"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts index ca7c27b95d5..84cb533058b 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-feature-source.ts @@ -1,18 +1,10 @@ /** - * Build a {@link FeatureSource} for {@link nameClustersByDistinctiveFeatures} over the worker's - * stores. This is the seam where the distinctive-feature namer (a pure module) meets the - * worker's by-value indexes: + * Adapts the worker's stores into a {@link FeatureSource} for + * {@link nameClustersByDistinctiveFeatures}. * - * - exact `(property = value)` features and raw numeric/date readings come from the - * {@link PropertyStore}, - * - link/target-type features come from the {@link LinkStore}: for each link a member - * participates in, the PRIMARY type of the entity at the other end, resolved to its title - * via the type registry -- the answer to "what does this group link TO". The target's - * SUB-cluster would be a sharper signal, but those clusters don't exist yet at naming time; - * the target's type is the coarse proxy available now. - * - * Feature keys are namespaced strings (`p`/`lt`/`n` + NUL-separated fields) so the namer can - * treat them as opaque while this module decodes them in {@link FeatureSource.describe}. + * Feature keys are namespaced strings (`p`/`lt`/`n` + NUL-separated fields); + * the namer treats them as opaque and this module decodes them in + * {@link FeatureSource.describe}. */ import { primaryTypeOfSet } from "../entity-style"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts index be7e79edfb2..4d80386dffb 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/cluster-tree.ts @@ -2,6 +2,7 @@ import { MutableCircle } from "../../geometry"; import { ClusterId } from "../../ids"; import { hslToRgb } from "../../math/color"; +import { murmur3StringUnit } from "../../math/hash"; import { graphColors } from "../../visual-style"; import { Column } from "../collections/column"; import { subclusterByLinks } from "./community"; @@ -13,15 +14,9 @@ import type { LinkStore } from "../store/link"; import type { TypeRegistry } from "../store/type-registry"; import type { TypeSetGroup, TypeSetStore } from "../store/type-set"; -/* eslint-disable no-bitwise */ function stableHashToAngle(id: string): number { - let hash = 0; - for (let idx = 0; idx < id.length; idx++) { - hash = ((hash << 5) - hash + id.charCodeAt(idx)) | 0; - } - return ((hash >>> 0) / 0xffffffff) * 2 * Math.PI; + return murmur3StringUnit(id) * 2 * Math.PI; } -/* eslint-enable no-bitwise */ export class ClusterLabel { readonly text: string; @@ -190,102 +185,11 @@ function stableSortNodes(nodes: ClusterNode[]): ClusterNode[] { ); } -function mostSimilarSibling( - target: ClusterNode, - siblings: ClusterNode[], -): ClusterNode | undefined { - let best: ClusterNode | undefined; - let bestOverlap = 0; - - for (const sibling of siblings) { - if (sibling.id === target.id || sibling.circle.isOrigin) { - continue; - } - - let overlap = 0; - for (const [typeIdx, mass] of target.mass.closure) { - const siblingMass = sibling.mass.closure.get(typeIdx); - if (siblingMass !== undefined) { - overlap += Math.min(mass, siblingMass); - } - } - - if (overlap > bestOverlap) { - bestOverlap = overlap; - best = sibling; - } - } - - return best; -} - -function clampChildrenToParent( - children: readonly ClusterNode[], - parent: ClusterNode, - padding: number, -): void { - for (const child of children) { - const dx = child.circle.x - parent.circle.x; - const dy = child.circle.y - parent.circle.y; - const dist = Math.hypot(dx, dy); - const maxDist = parent.circle.radius - child.circle.radius - padding; - - if (maxDist <= 0) { - // Child is too large to fit: center it. - child.circle.x = parent.circle.x; - child.circle.y = parent.circle.y; - } else if (dist > maxDist) { - const scale = maxDist / dist; - child.circle.x = parent.circle.x + dx * scale; - child.circle.y = parent.circle.y + dy * scale; - } - } -} - -function resolveCollisions( - nodes: readonly ClusterNode[], - iterations: number, -): void { - const padding = 4; - - for (let iter = 0; iter < iterations; iter++) { - let anyOverlap = false; - - for (let idx = 0; idx < nodes.length; idx++) { - const a = nodes[idx]!; - for (let jdx = idx + 1; jdx < nodes.length; jdx++) { - const b = nodes[jdx]!; - - if (a.circle.pushApart(b.circle, padding)) { - anyOverlap = true; - } else if ( - Math.hypot(b.circle.x - a.circle.x, b.circle.y - a.circle.y) <= 0.001 - ) { - // Coincident: pushApart can't determine direction. - // Separate along a deterministic angle derived from IDs. - anyOverlap = true; - const angle = stableHashToAngle(`${a.id}${b.id}`); - a.circle.x -= Math.cos(angle) * 2; - a.circle.y -= Math.sin(angle) * 2; - b.circle.x += Math.cos(angle) * 2; - b.circle.y += Math.sin(angle) * 2; - } - } - } - - if (!anyOverlap) { - break; - } - } -} - -/** Count -> leaf radius: area proportional to count (the same sqrt(count)*k mapping at every level). */ +/** Area-proportional radius: sqrt(count) * k. */ const RADIUS_PER_SQRT_COUNT = 5; -/** Floor so the smallest leaves stay visible. */ const LEAF_MIN_RADIUS = 8; -/** Larger floor for top-level bubbles, so a singleton type stays clickable. */ +/** Larger floor so singleton-type top-level bubbles stay clickable. */ const TOP_LEVEL_MIN_RADIUS = 15; -/** Inter-child gap and rim margin used when sizing a container to fit. */ const ENCLOSE_GAP = 2; const ENCLOSE_PADDING = 3; @@ -610,8 +514,7 @@ export class ClusterTree { return sum; } - /** Human-readable dump of the whole tree, for debugging (label, count, kind, - * radius, child count, id), indented by depth. */ + /** Human-readable dump of the whole tree, indented by depth. */ debugDump(): string { const lines: string[] = []; const visit = (node: ClusterNode, depth: number): void => { @@ -628,15 +531,11 @@ export class ClusterTree { return lines.join("\n"); } - /** - * Full pipeline: classify, materialize, label, hierarchy, layout. - * Cold-start path used on the first ingest batch. - */ + /** Full rebuild pipeline. Cold-start path for the first ingest batch. */ rebuild( typeSets: TypeSetStore, types: TypeRegistry, config: VizConfig, - totalNodeCount: number, ): void { for (const group of typeSets) { group.recomputeClosure(types); @@ -647,11 +546,11 @@ export class ClusterTree { this.#materializeClusters(typeSets); this.#computeLabels(types); this.#buildDisplayHierarchy(types, config); - this.#layoutTopLevel(totalNodeCount); + this.#layoutTopLevel(); } /** - * Incremental path. Handles count growth, new groups, + * Incremental update. Handles count growth, new groups, * threshold promotion, and re-targeting of small groups. */ updateIncrementally( @@ -659,7 +558,6 @@ export class ClusterTree { typeSets: TypeSetStore, types: TypeRegistry, config: VizConfig, - totalNodeCount: number, ): void { const dirtyIds = new Set(); let needsHierarchyRebuild = false; @@ -767,12 +665,11 @@ export class ClusterTree { this.#buildDisplayHierarchy(types, config); } - this.#stableLayout(totalNodeCount); + this.#stableLayout(); } /** - * Lazy subdivision trigger. Synchronously runs community - * detection when a leaf cluster is too large for entity reveal. + * Synchronously subdivide a leaf cluster via community detection. * Returns true if children were created. */ ensureSubclusters( @@ -874,11 +771,8 @@ export class ClusterTree { } /** - * Replace a node's label text — used to drop a distinctive-feature name over the - * placeholder of an embedding group ("Similar group n", {@link applyEmbeddingResult}) or a - * grouping-fallback group ("Group n": link-signature `community` + `entity-bucket` chunks). - * None of those kinds are touched by `#computeLabels`/`#relabelDirty` (those are type-set - * only), so the new text survives subsequent commits. + * Replace a node's label text. Safe to call on embedding or community + * nodes: their labels are not overwritten by `#computeLabels`. */ setLabelText(id: ClusterId, text: string): void { const node = this.#nodes.get(id); @@ -1089,7 +983,6 @@ export class ClusterTree { node.count += source.count; node.mass.absorb(source.mass); } - // Without a label a rollup renders as a nameless "(count)" bubble. if (label) { node.label = label; } @@ -1213,13 +1106,10 @@ export class ClusterTree { } /** - * Bottom-up circle-packing radii. A family rollup grows to enclose its - * (already-sized) children so two small siblings never overlap inside a - * container that was otherwise sized by its small aggregate count. Every other - * node is sized by entity count (area proportional to count). Subdivided type-sets are - * sized/packed top-down elsewhere (#layoutChildrenInParent) and so are left - * count-based here, drilling into one must not reflow the top level. The - * per-node radius is then read by the force layout and the renderer. + * Bottom-up circle-packing radii. Family rollups grow to enclose their + * children so small siblings never overlap inside a count-sized container. + * Subdivided type-sets are sized top-down in `#layoutChildrenInParent` + * and stay count-based here so drilling in doesn't reflow the top level. */ #assignRadii(): void { for (const child of this.#root.children) { @@ -1245,7 +1135,7 @@ export class ClusterTree { } } - #layoutTopLevel(totalCount: number): void { + #layoutTopLevel(): void { const children = this.#root.children; if (children.length === 0) { return; @@ -1253,20 +1143,13 @@ export class ClusterTree { this.#assignRadii(); - const angleStep = (2 * Math.PI) / children.length; - const baseRadius = Math.max(100, Math.sqrt(totalCount) * 4); - for (let idx = 0; idx < children.length; idx++) { const child = children[idx]!; child.circle.radius = Math.max(TOP_LEVEL_MIN_RADIUS, child.circle.radius); - child.circle.x = Math.cos(angleStep * idx) * baseRadius; - child.circle.y = Math.sin(angleStep * idx) * baseRadius; } - - resolveCollisions(children, 80); } - #stableLayout(totalCount: number): void { + #stableLayout(): void { const children = this.#root.children; if (children.length === 0) { return; @@ -1274,31 +1157,9 @@ export class ClusterTree { this.#assignRadii(); - const baseRadius = Math.max(100, Math.sqrt(totalCount) * 4); - for (const child of children) { - const isNew = child.circle.isOrigin; child.circle.radius = Math.max(TOP_LEVEL_MIN_RADIUS, child.circle.radius); - - if (isNew) { - const sibling = mostSimilarSibling(child, children); - if (sibling) { - const angle = stableHashToAngle(child.id); - child.circle.x = - sibling.circle.x + - Math.cos(angle) * (sibling.circle.radius + child.circle.radius + 8); - child.circle.y = - sibling.circle.y + - Math.sin(angle) * (sibling.circle.radius + child.circle.radius + 8); - } else { - const angle = stableHashToAngle(child.id); - child.circle.x = Math.cos(angle) * baseRadius; - child.circle.y = Math.sin(angle) * baseRadius; - } - } } - - resolveCollisions(children, 80); } #layoutChildrenInParent(parent: ClusterNode): void { @@ -1308,36 +1169,13 @@ export class ClusterTree { } const totalCount = children.reduce((sum, child) => sum + child.count, 0); - const padding = 4; - // Size children proportional to their entity count. - // Reserve space so child + placement fits inside the parent. for (const child of children) { child.circle.radius = Math.max( 8, parent.circle.radius * Math.sqrt(child.count / totalCount) * 0.6, ); } - - // Place on a ring inside the parent. The ring radius is chosen - // so that child circles don't immediately poke out. - const maxChildR = children.reduce( - (max, child) => Math.max(max, child.circle.radius), - 0, - ); - const availableRadius = parent.circle.radius - maxChildR - padding; - const innerRadius = Math.max(0, availableRadius * 0.65); - - const angleStep = (2 * Math.PI) / children.length; - for (let idx = 0; idx < children.length; idx++) { - children[idx]!.circle.x = - parent.circle.x + Math.cos(angleStep * idx) * innerRadius; - children[idx]!.circle.y = - parent.circle.y + Math.sin(angleStep * idx) * innerRadius; - } - - resolveCollisions(children, 80); - clampChildrenToParent(children, parent, padding); } #propagateCountsToRoot(dirtyIds: Set): void { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts index 1fa062619f7..245d4b001ae 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/community.ts @@ -1,11 +1,14 @@ /** * Community detection for sub-clustering large type-set clusters. * - * Runs lazily when a cluster is about to open and is too large to - * show individual entities. Uses connected components first, then - * bounded label propagation for large components. + * Runs lazily when a cluster is about to open and is too large to show + * individual entities. Connected components are extracted first; large + * components are further split by bounded label propagation. */ + import { ClusterId } from "../../ids"; +import { murmur3String } from "../../math/hash"; +import { deterministicShuffle } from "../../math/random"; import { Column } from "../collections/column"; import { type CsrGraph, @@ -18,25 +21,6 @@ import type { VizConfig } from "../../config"; import type { EntityIndex } from "../../ids"; import type { LinkStore } from "../store/link"; -/* eslint-disable no-bitwise */ -function deterministicShuffle(indices: number[], seed: number): number[] { - const result = [...indices]; - let state = seed * 2654435761; - - for (let idx = result.length - 1; idx > 0; idx--) { - state = (state ^ (state << 13)) | 0; - state = (state ^ (state >>> 17)) | 0; - state = (state ^ (state << 5)) | 0; - const target = (state >>> 0) % (idx + 1); - const temp = result[idx]!; - result[idx] = result[target]!; - result[target] = temp; - } - - return result; -} -/* eslint-enable no-bitwise */ - export function boundedLabelPropagation( graph: CsrGraph, component: number[], @@ -165,7 +149,8 @@ function topDegreeEntity( let bestDegree = 0; for (const entityIdx of members) { - const degree = links.linksFor(entityIdx).length; + const degree = links.degreeOf(entityIdx); + if (degree > bestDegree) { bestDegree = degree; bestIdx = entityIdx; @@ -202,10 +187,7 @@ function featureKeyToLabel(key: string): string { return `${scope} ${direction} links`; } -/** - * Label communities using overrepresented link features, - * scored with TF-IDF across sibling communities. - */ +/** Label communities using overrepresented link features (TF-IDF across siblings). */ function labelAllCommunities( communityMembers: EntityIndex[][], children: ClusterNode[], @@ -270,32 +252,27 @@ function labelAllCommunities( } } -/* eslint-disable no-bitwise */ function linkSignatureKey( entityIdx: EntityIndex, links: LinkStore, maxBuckets: number, ): string { - const endpoints = links.linksFor(entityIdx); - if (endpoints.length === 0) { + const degree = links.degreeOf(entityIdx); + if (degree === 0) { return "isolated"; } const features = new Set(); - for (const endpoint of endpoints) { + for (const endpoint of links.linksFor(entityIdx)) { features.add(`${endpoint.direction}:${endpoint.typeSetId}`); } const sorted = [...features].sort(); const key = sorted.join("|"); - let hash = 0; - for (let idx = 0; idx < key.length; idx++) { - hash = ((hash << 5) - hash + key.charCodeAt(idx)) | 0; - } - return `sig:${(hash >>> 0) % maxBuckets}`; + // eslint-disable-next-line no-bitwise + return `sig:${(murmur3String(key) >>> 0) % maxBuckets}`; } -/* eslint-enable no-bitwise */ function columnFromIndices( indices: ArrayLike, @@ -346,10 +323,9 @@ function coarseLinkSignatureBuckets( } /** - * Last-resort partitioning when link-based community detection - * can't produce meaningful groups (e.g. 0 internal edges). - * Splits entities into roughly equal chunks. These are placeholders - * that embedding k-means can later replace with semantic groups. + * Last-resort partitioning when community detection can't produce + * meaningful groups (e.g. zero internal edges). Splits entities into + * roughly equal chunks that embedding k-means can later replace. */ function deterministicPartition( cluster: ClusterNode, @@ -382,12 +358,11 @@ function deterministicPartition( } /** - * Full sub-clustering pipeline for a single cluster. - * Returns child ClusterNodes, or empty array if the cluster - * is small enough to show entities directly. + * Sub-cluster a single cluster into child nodes. * - * Cascade: community detection -> deterministic partition. - * Always returns >= 2 children for clusters above entityRevealMax. + * Returns an empty array if the cluster is small enough to show entities + * directly, otherwise >= 2 children. Falls through community detection + * to deterministic partition as a last resort. */ export function subclusterByLinks( cluster: ClusterNode, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts index 0d81690697e..90b8e9ce600 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/distinctive-cluster-label.ts @@ -1,39 +1,26 @@ /** - * Name a set of sibling clusters by their distinctive shared FEATURES. + * Name a set of sibling clusters by their distinctive shared features. * - * Some upstream step (kmeans over embeddings, or the grouping fallback) has already grouped - * the entities; this finds a meaningful NAME for each group from the features its members - * share but its siblings don't. It is the same coverage x idf scoring the type labeler uses - * ({@link distinctiveLabel}), generalised over a UNIFIED feature space: + * Given clusters that are already grouped (by embeddings or a grouping fallback), + * this finds a meaningful label for each group from the features its members share + * but its siblings don't. Features come from three sources: * * - exact `(property = value)` pairs (e.g. `Destination = "foo"`), - * - numeric/date RANGES (e.g. `Quantity 100–500`), bucketed per-subdivision from the live - * distribution of the siblings (so a group split off by magnitude gets a range name even - * though no single exact value is common to it), and - * - LINK target types (e.g. `→ Material`) -- what a group's members link TO, the only signal - * that distinguishes entities whose own properties are uniform (e.g. batches). + * - numeric/date ranges (e.g. `Quantity 100–500`), bucketed per-subdivision from + * the live distribution of the siblings, and + * - link target types (e.g. `→ Material`). * - * For every feature: - * - coverage = fraction of a cluster's members that carry it (it must be COMMON within the - * group), and - * - idf = inverse document frequency across the SIBLING clusters (it must be RARE across - * them -- "depending on its pre-clustering"). + * Scoring is coverage * IDF: a feature must be common within the cluster (coverage + * >= {@link MIN_COVERAGE}) and rare across siblings (high IDF). Features are grouped + * by a dedup key so a compound label never repeats a dimension. * - * So a feature every group shares scores ~0 and is ignored, while one characteristic of a - * single group wins. Features are grouped by a dedup GROUP key (all values/ranges of one - * property, and each link target, form a group) so a compound label never repeats a - * dimension and an exact value and its range can't both appear. + * Collision breaking has two stages: first, extend colliding labels with the next + * most distinctive feature; then, when characteristic features are exhausted, separate + * on the feature where the groups most differ. Genuinely indistinguishable groups + * share a name. * - * Collisions are broken in two stages. First, two groups landing on the same label are each - * extended with their next-most-distinctive CHARACTERISTIC feature. When that is exhausted - * because the groups genuinely share every >= MIN_COVERAGE feature, they are separated on the - * feature where they MOST differ -- searched across ALL coverages, not just the dominant ones. - * Only two truly indistinguishable groups stay sharing a name. - * - * The scan is bounded: a cluster with more than {@link MAX_SAMPLE_MEMBERS} members is named - * from a deterministic, evenly-spread SAMPLE of that many, so naming cost stays flat on huge - * clusters. The chosen parts render in a deterministic order and join onto separate lines, so - * a multi-part label reads as a stable little table inside the bubble and never reorders. + * Cost is bounded: clusters above {@link MAX_SAMPLE_MEMBERS} are named from a + * deterministic, evenly-spread sample. Labels render in deterministic sort order. */ import type { ClusterId, EntityIndex } from "../../ids"; @@ -42,17 +29,15 @@ export interface ClusterMembers { readonly memberIdxs: Int32Array; } -/** What a feature renders to in a label, plus the group it dedups within and its sort key. */ export interface FeatureDescriptor { /** Dedup group: at most one part per group appears in a compound label. */ readonly group: string; - /** The fully-rendered label line, e.g. `Destination = "foo"`, `Quantity 100–500`, `→ Material`. */ + /** Rendered label line, e.g. `Destination = "foo"`, `Quantity 100–500`, `→ Material`. */ readonly text: string; - /** Stable key the parts of a label sort by (so a label never reshuffles its lines). */ + /** Stable sort key so a multi-part label never reshuffles its lines. */ readonly sortKey: string; } -/** A raw numeric reading of one member, to be bucketed into a range per-subdivision. */ export interface NumericReading { /** Stable per-property axis key; range buckets are computed per dimension. */ readonly dimension: string; @@ -60,7 +45,6 @@ export interface NumericReading { readonly value: number; } -/** Title + kind of a numeric axis, for rendering its range buckets. */ export interface NumericDimension { /** Dedup group, shared with the property's exact features so a property yields one part. */ readonly group: string; @@ -70,18 +54,17 @@ export interface NumericDimension { } /** - * Supplies per-member features for naming. Implemented in the worker over its stores - * ({@link createClusterFeatureSource}); mocked directly in tests. The namer treats every - * key as opaque except numeric readings, whose range bucketing it owns. + * Supplies per-member features for naming. The namer treats every key as + * opaque except numeric readings, whose range bucketing it owns. */ export interface FeatureSource { /** Stable feature keys for a member (exact property + link/target-type). */ keysOf(member: EntityIndex): Iterable; - /** Raw numeric/date readings for a member, for per-subdivision range bucketing. */ + /** Raw numeric/date readings for a member. */ numericsOf(member: EntityIndex): Iterable; - /** Describe a key returned by {@link keysOf}, or undefined to ignore it. */ + /** Describe a key returned by {@link keysOf}, or undefined to skip it. */ describe(key: string): FeatureDescriptor | undefined; - /** Describe a numeric dimension (title + kind), or undefined to ignore it. */ + /** Describe a numeric dimension, or undefined to skip it. */ describeNumeric(dimension: string): NumericDimension | undefined; } @@ -90,11 +73,11 @@ const MIN_COVERAGE = 0.6; /** Most parts joined into one compound label (collision breaking). */ const MAX_LABEL_PARTS = 3; /** - * A discriminative tie-break feature need only be reasonably common in its cluster (the - * groups already share their dominant features, so the separator lives below MIN_COVERAGE). + * A discriminative tie-break feature need only be reasonably common (the groups + * already share their dominant features, so the separator lives below MIN_COVERAGE). */ const DISCRIMINATOR_MIN_COVERAGE = 0.34; -/** ...and it must actually separate: this much more prevalent here than in the colliding peers. */ +/** Minimum coverage gap between this cluster and the colliding peers. */ const DISCRIMINATOR_MIN_GAP = 0.2; /** Discriminative passes to attempt once characteristic compounding is exhausted. */ const MAX_DISCRIMINATOR_PASSES = 2; @@ -102,17 +85,16 @@ const MAX_DISCRIMINATOR_PASSES = 2; /** Cap the members scanned per cluster; bigger clusters name from an even sample of this many. */ const MAX_SAMPLE_MEMBERS = 5000; /** - * A numeric axis needs at least this many DISTINCT values across the subdivision to be range - * bucketed; below it the property is low-cardinality and its exact value features name it. + * A numeric axis needs at least this many distinct values across the subdivision + * to be range-bucketed; below that, exact value features name it instead. */ const MIN_NUMERIC_DISTINCT = 8; -/** Per-subdivision range buckets for one numeric axis. */ interface NumericRange { readonly describe: NumericDimension; /** Strictly-increasing interior edges; bucket b spans [edges[b-1], edges[b]). */ readonly edges: number[]; - /** Observed [min, max] of the values that fall in each bucket (parallel to edges + 1). */ + /** Observed [min, max] per bucket (parallel to `edges.length + 1`). */ readonly bounds: { min: number; max: number }[]; } @@ -121,10 +103,7 @@ interface Candidate extends FeatureDescriptor { readonly score: number; } -/** - * A deterministic, evenly-spread sample of at most `max` members. Returns the input - * unchanged when it already fits, so small clusters are scanned in full. - */ +/** Deterministic, evenly-spread sample of at most `max` members. */ function subsample(members: Int32Array, max: number): Int32Array { if (members.length <= max) { return members; @@ -202,13 +181,11 @@ function rangeDescriptor( } /** - * Per-axis range buckets for the whole subdivision. Bucket edges sit at the MIDPOINTS between - * the clusters' median values (not at global quantiles): a group split off by magnitude - * occupies a contiguous band, so a midpoint between its median and its neighbour's keeps that - * whole band in ONE bucket -- where equal-frequency quantiles would slice the band in two and - * leave its coverage below MIN_COVERAGE. Only high-cardinality axes are bucketed (low-cardinality - * ones name fine by exact value), and only those with two or more distinct cluster medians (no - * contrast, no point). + * Per-axis range buckets for the whole subdivision. Bucket edges sit at the + * midpoints between the clusters' median values: a group split off by magnitude + * occupies a contiguous band, so a midpoint keeps that whole band in one bucket. + * Equal-frequency quantiles would slice the band in two and leave coverage below + * {@link MIN_COVERAGE}. */ function buildNumericRanges( samples: readonly Int32Array[], @@ -284,9 +261,9 @@ function buildNumericRanges( } /** - * Coverage of every feature in one cluster (fraction of sampled members carrying it), - * registering each feature's descriptor as it is first seen. A member counts a feature once, - * however many of its links/values produce it. + * Coverage of every feature in one cluster (fraction of sampled members carrying it). + * Descriptors are registered as features are first seen. Each member counts a feature + * at most once. */ function clusterCoverage( sample: Int32Array, @@ -342,8 +319,8 @@ function clusterCoverage( } /** - * Rank a cluster's distinctive candidates: keep only the best-scoring feature PER GROUP (so a - * compound label never repeats a property and reaches for a different one), distinctive first. + * Rank a cluster's distinctive candidates, keeping the best-scoring feature per + * dedup group so a compound label never repeats a dimension. */ function rankCandidates( coverage: Map, @@ -400,9 +377,9 @@ function rankCandidates( } /** - * The feature most over-represented in `here` versus the colliding `peers`, drawn from ALL - * of this cluster's features (not just the >= MIN_COVERAGE ones), excluding groups already in - * the label. Undefined when nothing separates them well enough. + * The feature most over-represented in `here` versus the colliding `peers`, + * excluding groups already used. Returns undefined when nothing separates + * them above {@link DISCRIMINATOR_MIN_GAP}. */ function bestDiscriminator( here: Map, @@ -450,9 +427,8 @@ function bestDiscriminator( } /** - * Render a label's parts to its display string: parts ordered deterministically by sort key - * (then text) and placed one per line. The stable order means the same SET of parts always - * renders to the same string, so collision detection is exact. + * Render parts to their display string: deterministic sort order, one per line. + * The stable ordering guarantees collision detection is exact. */ function renderLabel(parts: readonly FeatureDescriptor[]): string { return [...parts] @@ -466,9 +442,8 @@ function renderLabel(parts: readonly FeatureDescriptor[]): string { } /** - * Turn ranked candidates into labels, extending colliding clusters until their labels - * separate (characteristic compounding first, then a discriminative tie-break) or the - * label-part budget is spent. + * Turn ranked candidates into labels, extending colliding clusters until they + * separate or the label-part budget is spent. */ function resolveLabels( clusters: readonly ClusterMembers[], @@ -483,7 +458,6 @@ function resolveLabels( (parts) => new Set(parts.map((part) => part.group)), ); - // Indices of named clusters grouped by identical rendered label (size > 1 = a collision). const collisions = (): number[][] => { const byLabel = new Map(); for (let index = 0; index < clusters.length; index++) { @@ -501,7 +475,7 @@ function resolveLabels( return [...byLabel.values()].filter((bucket) => bucket.length > 1); }; - // Phase 1: lengthen colliding clusters with their next CHARACTERISTIC candidate. + // Phase 1: lengthen colliding clusters with their next characteristic candidate. for (let pass = 1; pass < MAX_LABEL_PARTS; pass++) { const groups = collisions(); if (groups.length === 0) { @@ -528,8 +502,8 @@ function resolveLabels( } } - // Phase 2: clusters that still collide share every characteristic feature -- separate them - // on the feature where they MOST differ. Genuinely identical groups find nothing and stay. + // Phase 2: clusters that still collide share every characteristic feature. + // Separate them on the feature where they most differ. for (let pass = 0; pass < MAX_DISCRIMINATOR_PASSES; pass++) { const groups = collisions(); if (groups.length === 0) { @@ -575,8 +549,8 @@ function resolveLabels( } /** - * Distinctive label per cluster. Only clusters with a confident, distinctive signature appear - * in the result; the rest keep their placeholder. + * Compute a distinctive label for each cluster. Clusters without a confident + * distinctive signature are omitted from the result and keep their placeholder. */ export function nameClustersByDistinctiveFeatures( clusters: readonly ClusterMembers[], @@ -584,22 +558,18 @@ export function nameClustersByDistinctiveFeatures( ): Map { const clusterCount = clusters.length; - // Bound the scan: name big clusters from an even sample so cost stays flat. const samples = clusters.map((cluster) => subsample(cluster.memberIdxs, MAX_SAMPLE_MEMBERS), ); - // Numeric/date axes are bucketed from the whole subdivision's distribution first. const ranges = buildNumericRanges(samples, source); - // Per-cluster feature coverage (fraction of members sharing each feature), building the - // shared descriptor table as features are encountered. const descriptors = new Map(); const coverages = samples.map((sample) => clusterCoverage(sample, source, ranges, descriptors), ); - // Document frequency: how many clusters a feature is CHARACTERISTIC of (>= MIN_COVERAGE). + // Document frequency: how many clusters a feature covers at >= MIN_COVERAGE. const documentFrequency = new Map(); for (const coverage of coverages) { for (const [key, fraction] of coverage) { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts index e2a2f02b2e8..98f5692d17b 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/hierarchy/lod.ts @@ -5,10 +5,6 @@ * Each node in the cut has a mode: render as bubble, show children, * or show individual entities. Hysteresis prevents flickering * at threshold boundaries. - * - * Transitions (spec 4.4) are not yet implemented: LOD changes - * snap immediately rather than animating. Needs Deck.gl transition - * support or manual interpolation on the main thread. */ import { Bbox, screenRadius } from "../../geometry"; @@ -37,22 +33,25 @@ function setsEqual(lhs: Set, rhs: Set): boolean { if (lhs.size !== rhs.size) { return false; } + for (const item of lhs) { if (!rhs.has(item)) { return false; } } + return true; } /** * Tracks the previous LOD decisions for hysteresis. - * Open/close thresholds differ so clusters don't flicker at boundaries. + * Open/close thresholds differ so a cluster that just opened + * requires a smaller screen radius to close than it took to open. */ export class LodState { - readonly #visibleIds = new Set(); - readonly #showingChildren = new Set(); - readonly #showingEntities = new Set(); + #visibleIds = new Set(); + #showingChildren = new Set(); + #showingEntities = new Set(); wasShowingChildren(clusterId: ClusterId): boolean { return this.#showingChildren.has(clusterId); @@ -83,11 +82,7 @@ export class LodState { return { visible, children, entities }; } - /** - * Non-destructive probe: would applying this cut change the committed - * open-state? Lets a caller decide whether a frame is needed without - * mutating hysteresis state. - */ + /** Would applying this cut change the committed open-state? */ wouldChange(items: readonly LodItem[]): boolean { const { visible, children, entities } = this.#partition(items); return ( @@ -99,6 +94,7 @@ export class LodState { /** * Commit a new visible cut. Returns true if the cut changed. + * * This is the single point where open-state is committed; call it * alongside force-layout creation/destruction so they never diverge. */ @@ -110,20 +106,9 @@ export class LodState { !setsEqual(children, this.#showingChildren) || !setsEqual(entities, this.#showingEntities); - this.#visibleIds.clear(); - for (const id of visible) { - this.#visibleIds.add(id); - } - - this.#showingChildren.clear(); - for (const id of children) { - this.#showingChildren.add(id); - } - - this.#showingEntities.clear(); - for (const id of entities) { - this.#showingEntities.add(id); - } + this.#visibleIds = visible; + this.#showingChildren = children; + this.#showingEntities = entities; return changed; } @@ -133,8 +118,8 @@ export class LodState { * Compute the visible cut: which clusters to render and in what mode. * * Walks the cluster tree top-down, using screen-space radius to decide - * whether to open each cluster. Uses a priority queue (largest screen - * radius first) and respects render budgets. + * whether to open each cluster. Largest screen radius is processed first; + * render budgets cap the total cluster and entity count. */ export function computeVisibleCut( tree: ClusterTree, @@ -143,8 +128,8 @@ export function computeVisibleCut( lodState: LodState, config: VizConfig, trySubdivide?: (node: ClusterNode) => boolean, - /** Clusters forced open regardless of zoom/viewport/budget (a pinned selection's leaf + - * ancestors). Ancestors open to children; the pinned leaf opens to entities. */ + /** Clusters forced open regardless of zoom, viewport, or budget. + * Ancestors open to children; the pinned leaf opens to entities. */ pinnedOpen?: ReadonlySet, ): LodItem[] { const root = tree.get(rootId); @@ -161,8 +146,8 @@ export function computeVisibleCut( viewport.zoom, ); - // Priority queue sorted by screen radius (largest first). - // Simple array + sort since cluster counts are bounded by maxRenderedClusters. + // Simple array sorted by screen radius (largest first). + // Cluster counts are bounded by maxRenderedClusters, so O(n²) splice is fine. const queue: ClusterNode[] = []; for (const child of root.children) { @@ -180,23 +165,20 @@ export function computeVisibleCut( while (queue.length > 0) { const node = queue.shift()!; - // The cut is viewport-independent for inclusion: an off-screen cluster is - // not dropped. Frustum culling is Deck.gl's job at render time; dropping a - // panned-off cluster from the cut used to make it vanish from the obstacle - // list, so edges suddenly re-routed "as if it never existed", and it churned - // a re-commit on every pan. Whether a cluster opens is still viewport-gated - // (centerInView, with hysteresis so it doesn't snap shut when panned out). + // Every cluster stays in the cut regardless of viewport position. Frustum + // culling is Deck.gl's job; removing a panned-off cluster from the cut made + // it vanish from the obstacle list, re-routing edges on every pan. + // + // Whether a cluster *opens* is viewport-gated (centerInView below, with + // hysteresis so it doesn't snap shut when panned partially off-screen). const rPx = screenRadiusPx(node, viewport.zoom); let hasChildren = node.children.length > 0; const viewMin = Math.min(viewport.width, viewport.height); - // Only open clusters whose center is inside the viewport. - // Already-open clusters stay open using the close threshold - // even if panned partially off-screen. const centerInView = viewBbox.containsPoint(node.circle.x, node.circle.y); - // Hysteresis: thresholds as fraction of viewport min dimension. + // Hysteresis: open/close thresholds differ, as fraction of viewport min dimension. const wasOpen = lodState.wasShowingChildren(node.id); const openChildren = wasOpen ? rPx >= config.closeChildrenFraction * viewMin @@ -207,11 +189,11 @@ export function computeVisibleCut( ? rPx >= config.closeEntitiesFraction * viewMin : centerInView && rPx >= config.openEntitiesFraction * viewMin; - // A pinned cluster (the selected node's leaf + ancestors) opens regardless of zoom, - // viewport, or budget -- it stays open until deselected (the birds-eye view). + // Pinned clusters (the selected node's leaf + ancestors) open regardless of + // zoom, viewport, or budget. They stay open until deselected. const pinned = pinnedOpen?.has(node.id) ?? false; - // Leaf cluster small enough to show entities? + // Leaf cluster small enough to reveal individual entities? if ( !hasChildren && node.count <= config.entityRevealMax && @@ -225,12 +207,11 @@ export function computeVisibleCut( } // Too large for entities and no children: try lazy subdivision. - // Only attempt when screen space warrants opening. if (!hasChildren && (openChildren || pinned) && trySubdivide?.(node)) { hasChildren = node.children.length > 0; } - // Has children and big enough to open? + // Big enough to open and has children to show? if ( hasChildren && (pinned || @@ -240,7 +221,6 @@ export function computeVisibleCut( ) { result.push({ clusterId: node.id, mode: "children" }); - // Add children to the queue, maintaining sort order. for (const child of node.children) { const childRPx = screenRadiusPx(child, viewport.zoom); let insertIdx = 0; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts index 23cc71524e8..cdf7c3ca32f 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout-cost.bench.ts @@ -28,9 +28,9 @@ import louvain from "graphology-communities-louvain"; // eslint-disable-next-line import/no-extraneous-dependencies import { bench, describe } from "vitest"; +import { parkMillerRng } from "../../math/random"; import { buildForceGraph } from "../bench-fixtures"; import { FlatGraphBuffer } from "../buffers/position-buffer"; -import { parkMillerRng } from "../random"; import { createCommunityLayout } from "./community-layout"; import { SparseStressSeeder } from "./sparse-stress-seed"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts index 1efc21576dc..3f8801e2929 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.test.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, it } from "vitest"; import { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts index e65bf9624d9..78a7111bb6a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-layout.ts @@ -38,7 +38,7 @@ import { } from "graphology-layout-forceatlas2"; import iterate from "graphology-layout-forceatlas2/iterate"; -import { parkMillerRng } from "../random"; +import { parkMillerRng } from "../../math/random"; import { SparseStressSeeder } from "./sparse-stress-seed"; import type { Fa2Tuning } from "../../config"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.test.ts new file mode 100644 index 00000000000..de17e999e0a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.test.ts @@ -0,0 +1,113 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { + countOverlaps, + overlapRelaxPass, + relaxOverlaps, +} from "./overlap-relax"; + +/** Grid of `side × side` points spaced `spacing` apart, all radius `radius`. */ +function grid( + side: number, + spacing: number, + radius: number, +): { x: Float32Array; y: Float32Array; radii: Float32Array; count: number } { + const count = side * side; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count).fill(radius); + for (let index = 0; index < count; index++) { + x[index] = (index % side) * spacing; + y[index] = Math.floor(index / side) * spacing; + } + return { x, y, radii, count }; +} + +describe("overlapRelaxPass", () => { + it("resolves all overlaps given enough passes (no-overlap invariant)", () => { + // 6×6 dots of radius 5 spaced only 4 apart: every neighbour overlaps. + const { x, y, radii, count } = grid(6, 4, 5); + expect(countOverlaps({ x, y, radii, count, padding: 0 })).toBeGreaterThan( + 0, + ); + + // Relax with a small positive padding: separation approaches the target from + // below (asymptotically), so a strict no-overlap check at padding 0 only holds + // once the target clears r_i + r_j by a margin. + relaxOverlaps(x, y, radii, count, { + padding: 2, + strength: 0.8, + maxPasses: 200, + minMove: 1e-3, + }); + + expect(countOverlaps({ x, y, radii, count, padding: 0 })).toBe(0); + }); + + it("leaves well-separated nodes untouched (returns zero move)", () => { + const { x, y, radii, count } = grid(5, 100, 5); + const beforeX = Float32Array.from(x); + const beforeY = Float32Array.from(y); + + const move = overlapRelaxPass({ + x, + y, + radii, + count, + padding: 0, + strength: 0.5, + }); + + expect(move).toBe(0); + expect([...x]).toEqual([...beforeX]); + expect([...y]).toEqual([...beforeY]); + }); + + it("separates exactly-coincident nodes deterministically", () => { + const x = new Float32Array([0, 0, 0]); + const y = new Float32Array([0, 0, 0]); + const radii = new Float32Array([5, 5, 5]); + + relaxOverlaps(x, y, radii, 3, { + padding: 2, + strength: 0.8, + maxPasses: 200, + minMove: 1e-3, + }); + + expect(countOverlaps({ x, y, radii, count: 3, padding: 0 })).toBe(0); + for (let index = 0; index < 3; index++) { + expect(Number.isFinite(x[index]!)).toBe(true); + expect(Number.isFinite(y[index]!)).toBe(true); + } + }); + + it("is deterministic for identical inputs", () => { + const first = grid(6, 4, 5); + const second = grid(6, 4, 5); + relaxOverlaps(first.x, first.y, first.radii, first.count, { + strength: 0.7, + }); + relaxOverlaps(second.x, second.y, second.radii, second.count, { + strength: 0.7, + }); + expect([...first.x]).toEqual([...second.x]); + expect([...first.y]).toEqual([...second.y]); + }); + + it("honours padding (enforces a gap beyond the radii)", () => { + const x = new Float32Array([0, 6]); + const y = new Float32Array([0, 0]); + const radii = new Float32Array([2, 2]); + // radii sum 4, but padding 6 ⇒ required centre distance 10. + relaxOverlaps(x, y, radii, 2, { + padding: 6, + strength: 0.8, + maxPasses: 200, + minMove: 1e-4, + }); + const distance = Math.hypot(x[1]! - x[0]!, y[1]! - y[0]!); + expect(distance).toBeGreaterThan(10 - 0.1); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.ts new file mode 100644 index 00000000000..f9e2e4e51dc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-relax.ts @@ -0,0 +1,257 @@ +/** + * Node-size overlap resolution for the stress-based layout. + * + * Stress / MDS layouts place nodes to match graph-theoretic distances and carry no + * notion of a node's drawn radius, so dots can overlap. This is the separable + * equivalent of ForceAtlas2's `adjustSizes` anti-overlap: a uniform-grid relaxation + * that pushes overlapping pairs apart, run as its own pass between solver ticks so + * the stress engine ({@link "./sparse-stress-seed"}) stays untouched. + * + * Deterministic: nodes are visited in index order, each unordered pair is resolved + * once, and coincident nodes separate along a hash-derived direction, so a seeded + * layout stays reproducible. + */ + +const EPS = 1e-6; + +/** + * Uniform-grid cell key. A string key is used deliberately: cell coordinates are + * unbounded and can be negative, so a packed numeric key risks collisions between + * distinct cells (which would fabricate neighbours and break determinism). + */ +function cellKey(cellX: number, cellY: number): string { + return `${cellX},${cellY}`; +} + +/* eslint-disable no-bitwise */ +/** Deterministic angle in [0, 2π) used to separate exactly-coincident nodes. */ +function coincidentAngle(nodeA: number, nodeB: number): number { + let hash = + (Math.imul(nodeA + 1, 2654435761) ^ Math.imul(nodeB + 1, 40503)) >>> 0; + hash ^= hash >>> 15; + return (hash / 0x1_0000_0000) * Math.PI * 2; +} +/* eslint-enable no-bitwise */ + +export interface OverlapGridInput { + readonly x: Float32Array; + readonly y: Float32Array; + readonly radii: ArrayLike; + readonly count: number; + /** Extra gap enforced beyond `radius_i + radius_j` (world units). */ + readonly padding: number; +} + +/** A grid snapshot: each node's cell, built once per pass so mid-pass moves stay consistent. */ +interface Grid { + readonly cellSize: number; + readonly cellX: Int32Array; + readonly cellY: Int32Array; + readonly buckets: ReadonlyMap; +} + +/** + * Build a uniform grid whose cell size guarantees any overlapping pair + * (centres closer than `2·maxRadius + padding`) lands in the same or an adjacent + * cell, so a 3×3 neighbourhood scan finds every overlap. + */ +function buildGrid({ x, y, radii, count, padding }: OverlapGridInput): Grid { + let maxRadius = 0; + for (let index = 0; index < count; index++) { + const radius = radii[index]!; + if (radius > maxRadius) { + maxRadius = radius; + } + } + + const cellSize = Math.max(EPS, 2 * maxRadius + Math.max(0, padding)); + const cellX = new Int32Array(count); + const cellY = new Int32Array(count); + const buckets = new Map(); + + for (let index = 0; index < count; index++) { + const gridX = Math.floor(x[index]! / cellSize); + const gridY = Math.floor(y[index]! / cellSize); + cellX[index] = gridX; + cellY[index] = gridY; + + const key = cellKey(gridX, gridY); + const bucket = buckets.get(key); + if (bucket) { + bucket.push(index); + } else { + buckets.set(key, [index]); + } + } + + return { cellSize, cellX, cellY, buckets }; +} + +export interface OverlapPassInput extends OverlapGridInput { + /** Fraction of each overlap corrected per pass, in (0, 1]. Lower = gentler. */ + readonly strength: number; +} + +/** + * One overlap-relaxation pass. Every overlapping pair is pushed apart symmetrically + * by `strength · overlap / 2`. Mutates `x`/`y` in place and returns the largest + * single-node displacement, so a caller can stop once a pass barely moves anything + * (no overlaps left ⇒ returns 0). + */ +export function overlapRelaxPass(input: OverlapPassInput): number { + const { x, y, radii, count, padding, strength } = input; + if (count < 2) { + return 0; + } + + const grid = buildGrid(input); + let maxMove = 0; + + for (let nodeA = 0; nodeA < count; nodeA++) { + const radiusA = radii[nodeA]!; + const baseCellX = grid.cellX[nodeA]!; + const baseCellY = grid.cellY[nodeA]!; + + for (let offsetX = -1; offsetX <= 1; offsetX++) { + for (let offsetY = -1; offsetY <= 1; offsetY++) { + const bucket = grid.buckets.get( + cellKey(baseCellX + offsetX, baseCellY + offsetY), + ); + if (!bucket) { + continue; + } + + for (const nodeB of bucket) { + // Resolve each unordered pair exactly once (also skips self). + if (nodeB <= nodeA) { + continue; + } + + const minDist = radiusA + radii[nodeB]! + padding; + let deltaX = x[nodeB]! - x[nodeA]!; + let deltaY = y[nodeB]! - y[nodeA]!; + const distSq = deltaX * deltaX + deltaY * deltaY; + if (distSq >= minDist * minDist) { + continue; + } + + let dist = Math.sqrt(distSq); + if (dist < EPS) { + const angle = coincidentAngle(nodeA, nodeB); + deltaX = Math.cos(angle); + deltaY = Math.sin(angle); + dist = EPS; + } else { + deltaX /= dist; + deltaY /= dist; + } + + const shift = (minDist - dist) * 0.5 * strength; + x[nodeA]! -= deltaX * shift; + y[nodeA]! -= deltaY * shift; + x[nodeB]! += deltaX * shift; + y[nodeB]! += deltaY * shift; + if (shift > maxMove) { + maxMove = shift; + } + } + } + } + } + + return maxMove; +} + +/** Number of overlapping pairs (centres closer than `radius_i + radius_j + padding`). */ +export function countOverlaps(input: OverlapGridInput): number { + const { x, y, radii, count, padding } = input; + if (count < 2) { + return 0; + } + + const grid = buildGrid(input); + let overlaps = 0; + + for (let nodeA = 0; nodeA < count; nodeA++) { + const radiusA = radii[nodeA]!; + const baseCellX = grid.cellX[nodeA]!; + const baseCellY = grid.cellY[nodeA]!; + + for (let offsetX = -1; offsetX <= 1; offsetX++) { + for (let offsetY = -1; offsetY <= 1; offsetY++) { + const bucket = grid.buckets.get( + cellKey(baseCellX + offsetX, baseCellY + offsetY), + ); + if (!bucket) { + continue; + } + + for (const nodeB of bucket) { + if (nodeB <= nodeA) { + continue; + } + const minDist = radiusA + radii[nodeB]! + padding; + const deltaX = x[nodeB]! - x[nodeA]!; + const deltaY = y[nodeB]! - y[nodeA]!; + if (deltaX * deltaX + deltaY * deltaY < minDist * minDist) { + overlaps += 1; + } + } + } + } + } + + return overlaps; +} + +export interface RelaxOverlapsOptions { + readonly padding?: number; + readonly strength?: number; + readonly maxPasses?: number; + /** Stop once a pass's largest displacement drops below this (world units). */ + readonly minMove?: number; +} + +export interface RelaxOverlapsResult { + readonly passes: number; + readonly lastMaxMove: number; +} + +/** + * Run overlap-relaxation passes until they converge (max displacement below + * `minMove`) or `maxPasses` is reached. Convenience wrapper over + * {@link overlapRelaxPass} for callers that resolve overlap in one shot (tests, + * batch use); the streaming layout calls {@link overlapRelaxPass} once per tick + * so the separation animates. + */ +export function relaxOverlaps( + x: Float32Array, + y: Float32Array, + radii: ArrayLike, + count: number, + options: RelaxOverlapsOptions = {}, +): RelaxOverlapsResult { + const padding = options.padding ?? 0; + const strength = options.strength ?? 0.5; + const maxPasses = options.maxPasses ?? 40; + const minMove = options.minMove ?? 0.05; + + let passes = 0; + let lastMaxMove = 0; + while (passes < maxPasses) { + lastMaxMove = overlapRelaxPass({ + x, + y, + radii, + count, + padding, + strength, + }); + passes += 1; + if (lastMaxMove < minMove) { + break; + } + } + + return { passes, lastMaxMove }; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.test.ts new file mode 100644 index 00000000000..f18fcbe4b18 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.test.ts @@ -0,0 +1,218 @@ +/* eslint-disable id-length */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { countOverlaps } from "./overlap-relax"; +import { SparseStressSolver } from "./sparse-stress-solver"; + +import type { SparseStressSolverOptions } from "./sparse-stress-solver"; + +const finiteCoords = (x: Float32Array, y: Float32Array): void => { + for (let i = 0; i < x.length; i++) { + expect(Number.isFinite(x[i])).toBe(true); + expect(Number.isFinite(y[i])).toBe(true); + } +}; + +/** A path graph 0-1-2-...-(n-1); an easy layout that settles quickly. */ +const pathGraph = (n: number): { src: Uint32Array; dst: Uint32Array } => { + const src = new Uint32Array(Math.max(0, n - 1)); + const dst = new Uint32Array(Math.max(0, n - 1)); + for (let i = 0; i < n - 1; i++) { + src[i] = i; + dst[i] = i + 1; + } + return { src, dst }; +}; + +/** + * A "double star": `leaves` middle nodes each linked to BOTH hubs. Every leaf sits at + * the same graph distance from everything, so pure stress collapses them onto one point + * (heavy overlap) — the fused proximity term is what must spread them. + */ +const doubleStar = ( + leaves: number, +): { n: number; src: Uint32Array; dst: Uint32Array } => { + const n = leaves + 2; + const hubA = 0; + const hubB = leaves + 1; + const src = new Uint32Array(leaves * 2); + const dst = new Uint32Array(leaves * 2); + for (let leaf = 0; leaf < leaves; leaf++) { + const node = leaf + 1; + src[leaf * 2] = hubA; + dst[leaf * 2] = node; + src[leaf * 2 + 1] = node; + dst[leaf * 2 + 1] = hubB; + } + return { n, src, dst }; +}; + +const solve = ( + n: number, + src: Uint32Array, + dst: Uint32Array, + options: SparseStressSolverOptions, + radii?: Float32Array, +): { x: Float32Array; y: Float32Array; epochs: number } => { + const result = new SparseStressSolver({ n, src, dst, radii }, options).run(); + return { x: result.x, y: result.y, epochs: result.epochs }; +}; + +/** Normalized RMS deviation of edge lengths from `ideal` (lower is better). */ +const edgeStress = ( + x: Float32Array, + y: Float32Array, + src: Uint32Array, + dst: Uint32Array, + ideal: number, +): number => { + let sum = 0; + for (let e = 0; e < src.length; e++) { + const u = src[e]!; + const v = dst[e]!; + const length = Math.hypot(x[v]! - x[u]!, y[v]! - y[u]!); + const ratio = length / ideal - 1; + sum += ratio * ratio; + } + return src.length > 0 ? Math.sqrt(sum / src.length) : 0; +}; + +describe("SparseStressSolver", () => { + it("is deterministic for identical inputs (seeded SGD + fused overlap)", () => { + const { n, src, dst } = doubleStar(20); + const radii = new Float32Array(n).fill(6); + const options: SparseStressSolverOptions = { + idealEdgeLength: 40, + overlapPadding: 2, + }; + + const first = solve(n, src, dst, options, radii.slice()); + const second = solve(n, src, dst, options, radii.slice()); + + for (let i = 0; i < n; i++) { + expect(first.x[i]).toBe(second.x[i]); + expect(first.y[i]).toBe(second.y[i]); + } + }); + + it("stops adaptively before the max-epoch horizon on an easy graph", () => { + const n = 24; + const { src, dst } = pathGraph(n); + const { epochs } = solve(n, src, dst, { + idealEdgeLength: 40, + minEpochs: 4, + maxEpochs: 80, + convergenceEpsilon: 1e-2, + }); + + expect(epochs).toBeGreaterThanOrEqual(4); + expect(epochs).toBeLessThan(80); + }); + + it("honours a fixed epoch count when `epochs` is supplied", () => { + const n = 24; + const { src, dst } = pathGraph(n); + const { epochs } = solve(n, src, dst, { idealEdgeLength: 40, epochs: 17 }); + + expect(epochs).toBe(17); + }); + + it("fused overlap term resolves the pile-ups that pure stress leaves behind", () => { + const { n, src, dst } = doubleStar(30); + const radii = new Float32Array(n).fill(6); + const base: SparseStressSolverOptions = { + idealEdgeLength: 40, + maxEpochs: 80, + }; + + // Pure stress (no radii) collapses the symmetric leaves onto one point. + const pure = solve(n, src, dst, base); + // Same solve with the fused proximity term active (radii + a firm weight). + const fused = solve( + n, + src, + dst, + { ...base, overlapPadding: 4, overlapWeight: 8 }, + radii.slice(), + ); + + const overlapArgs = { radii, count: n, padding: 0 } as const; + const pureOverlaps = countOverlaps({ + x: pure.x, + y: pure.y, + ...overlapArgs, + }); + const fusedOverlaps = countOverlaps({ + x: fused.x, + y: fused.y, + ...overlapArgs, + }); + + // This is a deliberately adversarial pile-up (full separation is geometrically + // impossible without stretching edges), but the fused term must still clear the + // large majority of the overlaps pure stress leaves behind. + expect(pureOverlaps).toBeGreaterThan(50); + expect(fusedOverlaps).toBeLessThan(pureOverlaps * 0.5); + }); + + it("keeps edge lengths near ideal while resolving overlap", () => { + const n = 40; + const { src, dst } = pathGraph(n); + const radii = new Float32Array(n).fill(5); + const { x, y } = solve( + n, + src, + dst, + { idealEdgeLength: 40, overlapPadding: 2, maxEpochs: 80 }, + radii, + ); + + finiteCoords(x, y); + expect(edgeStress(x, y, src, dst, 40)).toBeLessThan(1.5); + }); + + it("handles empty and tiny graphs", () => { + const empty = new SparseStressSolver({ + n: 0, + src: new Uint32Array(0), + dst: new Uint32Array(0), + }).run(); + expect(empty.x.length).toBe(0); + + const single = new SparseStressSolver({ + n: 1, + src: new Uint32Array(0), + dst: new Uint32Array(0), + }).run(); + expect(single.x.length).toBe(1); + finiteCoords(single.x, single.y); + + const pair = new SparseStressSolver( + { n: 2, src: new Uint32Array([0]), dst: new Uint32Array([1]) }, + { idealEdgeLength: 40 }, + ).run(); + finiteCoords(pair.x, pair.y); + expect( + Math.hypot(pair.x[1]! - pair.x[0]!, pair.y[1]! - pair.y[0]!), + ).toBeGreaterThan(0); + }); + + it("warm-continues from supplied positions (keepInitialPositions)", () => { + const x = new Float32Array([10, 20]); + const y = new Float32Array([5, 9]); + + const result = new SparseStressSolver( + { n: 2, src: new Uint32Array([0]), dst: new Uint32Array([1]), x, y }, + { + epochs: 0, + jitter: 0, + keepInitialPositions: true, + packComponents: false, + }, + ).run(); + + expect(result.x[1]! - result.x[0]!).toBeCloseTo(10, 5); + expect(result.y[1]! - result.y[0]!).toBeCloseTo(4, 5); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.ts new file mode 100644 index 00000000000..630f925879b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/sparse-stress-solver.ts @@ -0,0 +1,3024 @@ +/* + * Sparse-stress graph-layout SOLVER: a self-contained layout engine (not just a + * ForceAtlas2 seed) that minimizes the sparse-stress objective by SGD AND fuses an + * eta-scaled node-size proximity/overlap term into the same SGD step, so distance + * fidelity and non-overlap are optimized JOINTLY and re-tighten as the learning rate + * anneals. It also stops adaptively (convergence-based epoch count) rather than at a + * fixed epoch horizon. This file is a copy of sparse-stress-seed.ts (the FA2 seeder, + * left untouched) extended with those two capabilities. + * + * References: + * - Stress objective over graph-theoretic target distances: + * Emden R. Gansner, Yehuda Koren, Stephen North, + * "Graph Drawing by Stress Majorization" (2004). + * https://graphviz.org/documentation/GKN04.pdf + * + * - Node-size overlap modelled AS stress terms with target distance r_i + r_j + * (the fused proximity/overlap term here): Emden R. Gansner, Yifan Hu, + * "Efficient Node Overlap Removal Using a Proximity Stress Model" (PRISM, 2010), + * and the repulsive-stress hybrid of Emden R. Gansner, Yifan Hu, Stephen North, + * "A Maxent-Stress Model for Graph Layout" (IEEE TVCG 2013). Overlap removal as + * a distinct concern: Tim Dwyer, Kim Marriott, Peter J. Stuckey, + * "Fast Node Overlap Removal" (GD 2005). + * + * - Pairwise stress SGD update, per-pair relaxation cap mu <= 1, and + * exponential eta schedule: + * Jonathan X. Zheng, Samraat Pawar, Dan F. M. Goodman, + * "Graph Drawing by Stochastic Gradient Descent" (2018). + * https://arxiv.org/pdf/1710.04626 + * + * - Sparse/pivot stress idea for avoiding all-pairs stress terms: + * Mark Ortmann, Mirza Klimenta, Ulrik Brandes, + * "A Sparse Stress Model" (2017). + * https://jgaa.info/index.php/jgaa/article/view/paper440 + * See also the authors-of-SGD-adjacent reference implementation notes in + * s_gd2, especially `layout_sparse`. + * https://github.com/jxz12/s_gd2 + * + * - Landmark/Pivot-MDS-style use of distances from a small set of landmarks: + * Vin de Silva, Joshua B. Tenenbaum, + * "Sparse multidimensional scaling using landmark points" (2004), and + * Ulrik Brandes, Christian Pich, + * "Eigensolver Methods for Progressive Multidimensional Scaling of Large Data". + * + * - Directed-flow projection inspiration: WebCola's `flowLayout`, which + * creates separation constraints for directed edges not involved in cycles + * / strongly connected components. + * https://ialab.it.monash.edu/webcola/doc/classes/_layout_.layout.html + * + * - Intended downstream polish: ForceAtlas2 as described by Jacomy, + * Venturini, Heymann, and Bastian (2014). + * https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0098679 + * + */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-bitwise */ +/* eslint-disable id-length */ + +import { Column } from "../collections/column"; + +export const INF_DIST = 0xffff; +const MAX_STORED_DIST = 0xfffe; +const EPS = 1e-9; +const TAU = Math.PI * 2; + +export interface SparseStressSolverInput { + readonly n: number; + readonly src: Uint32Array; + readonly dst: Uint32Array; + + /** Optional output/input coordinate buffers. If provided, they are mutated. */ + readonly x?: Float32Array; + readonly y?: Float32Array; + + /** + * Optional per-node collision radii, in layout units. When provided, the solver + * fuses an eta-scaled proximity/overlap term into every SGD epoch that keeps + * same-component nodes at least `r_i + r_j + overlapPadding` apart (PRISM-style + * overlap-as-stress; Gansner & Hu 2010). Omit (or pass zeros) for pure stress. + */ + readonly radii?: Float32Array; +} + +export interface DirectedFlowOptions { + /** Default false. Adds a light y-axis separation projection for u -> v edges. */ + readonly enabled?: boolean; + + /** Minimum y[v] - y[u] separation. Multiplied by idealEdgeLength. Default 1. */ + readonly separation?: number; + + /** Projection strength. Small values are safer for cyclic/noisy graphs. Default 0.08. */ + readonly alpha?: number; + + /** Run projection every N stress epochs. Default 1. */ + readonly every?: number; + + /** Optional SCC labels. If absent and flow is enabled, labels are computed. */ + readonly sccLabels?: Int32Array; + + /** If true, project even within SCCs. Usually leave false. Default false. */ + readonly includeIntraScc?: boolean; +} + +export interface SparseStressSolverOptions { + /** Number of landmark pivots. Default is auto, capped at 256. */ + readonly pivotCount?: number; + + /** + * Fixed SGD epoch count. When set, the solver runs exactly this many epochs and + * the adaptive stopping below is disabled. When omitted, the epoch count is + * dynamic: the eta schedule anneals over `maxEpochs`, but the solver stops early + * once per-epoch movement settles (see `minEpochs`/`maxEpochs`/`convergenceEpsilon`). + */ + readonly epochs?: number; + + /** + * Lower bound on dynamic epochs, so the high-eta opening epochs (which always move + * a lot) never trip the early-stop. Ignored when `epochs` is set. Default 8. + */ + readonly minEpochs?: number; + + /** + * Upper bound on dynamic epochs and the horizon of the eta annealing schedule. + * Ignored when `epochs` is set. Default 60. + */ + readonly maxEpochs?: number; + + /** + * Adaptive-stop tolerance: stop once the largest per-node displacement in an epoch, + * divided by `idealEdgeLength`, stays below this for `convergenceStreak` epochs. + * Because eta has annealed to ~0 by the time movement is negligible, cutting the + * remaining epochs is safe. Ignored when `epochs` is set. Default 3e-3. + */ + readonly convergenceEpsilon?: number; + + /** Consecutive settled epochs required to stop early. Ignored when `epochs` is set. Default 3. */ + readonly convergenceStreak?: number; + + /** + * Extra gap, in layout units, enforced between node collision disks by the fused + * proximity/overlap term (added on top of `r_i + r_j`). Only used when `radii` is + * supplied. Default 1. + */ + readonly overlapPadding?: number; + + /** + * Relaxation weight for the fused proximity/overlap term, analogous to `edgeWeight`. + * The per-pair step is `min(overlapWeight * eta, 1)`, so a value near `edgeWeight` + * makes non-overlap roughly as insistent as edge length. Only used when `radii` is + * supplied. Default 1. + */ + readonly overlapWeight?: number; + + /** Layout-space length for one graph hop. Default 1. */ + readonly idealEdgeLength?: number; + + /** Edge relaxation weight. Default 1. */ + readonly edgeWeight?: number; + + /** Process at most this many pivots per epoch. Default: all pivots. */ + readonly pivotsPerEpoch?: number; + + /** Initial deterministic jitter, in layout units. Default 0.01. */ + readonly jitter?: number; + + /** Random/hash seed used only for deterministic jitter and tie breaking. Default 1. */ + readonly randomSeed?: number; + + /** Annealing epsilon used in stress SGD. Default 0.1. */ + readonly epsilon?: number; + + /** Keep existing x/y and only run stress from them. Default false. */ + readonly keepInitialPositions?: boolean; + + /** Pack disconnected weak components after stress. Default true. */ + readonly packComponents?: boolean; + + /** Component packing padding in ideal-edge units. Default 4. */ + readonly componentPadding?: number; + + /** Optional directed flow bias. Default disabled. */ + readonly directedFlow?: DirectedFlowOptions; + + /** Validate node ids and buffer lengths. Default true. */ + readonly validate?: boolean; + + /** Return the pivot distance matrix. Default false. */ + readonly returnPivotDistances?: boolean; +} + +class WeakComponents { + readonly count: number; + readonly labels: Int32Array; + readonly offsets: Uint32Array; + readonly nodes: Uint32Array; + readonly sizes: Uint32Array; + readonly seeds: Uint32Array; + + constructor({ + count, + labels, + offsets, + nodes, + sizes, + seeds, + }: { + readonly count: number; + readonly labels: Int32Array; + readonly offsets: Uint32Array; + readonly nodes: Uint32Array; + readonly sizes: Uint32Array; + readonly seeds: Uint32Array; + }) { + this.count = count; + this.labels = labels; + this.offsets = offsets; + this.nodes = nodes; + this.sizes = sizes; + this.seeds = seeds; + } + + static empty(): WeakComponents { + return new WeakComponents({ + count: 0, + labels: new Int32Array(0), + offsets: new Uint32Array(0), + nodes: new Uint32Array(0), + sizes: new Uint32Array(0), + seeds: new Uint32Array(0), + }); + } +} + +class Pivots { + readonly pivots: Uint32Array; + readonly components: Int32Array; + readonly distances: Uint16Array; + readonly diameter: number; + + constructor({ + pivots, + components, + distances, + diameter, + }: { + readonly pivots: Uint32Array; + readonly components: Int32Array; + readonly distances: Uint16Array; + readonly diameter: number; + }) { + this.pivots = pivots; + this.components = components; + this.distances = distances; + this.diameter = diameter; + } + + static unit(): Pivots { + return new Pivots({ + pivots: new Uint32Array(0), + components: new Int32Array(0), + distances: new Uint16Array(0), + diameter: 1, + }); + } +} + +export interface SparseStressSolverResult { + readonly x: Float32Array; + readonly y: Float32Array; + + readonly pivots: Pivots; + + readonly components: WeakComponents; + readonly epochs: number; + + readonly elapsed: number; +} + +export interface CsrGraph { + readonly offsets: Uint32Array; + readonly targets: Uint32Array; + readonly degree: Uint32Array; +} + +export type SparseStressSolverPhase = + | "setup" + | "weak-csr-degree" + | "weak-csr-prefix" + | "weak-csr-fill" + | "components-init" + | "components-scan" + | "pivot-min-fill" + | "pivot-row-fill" + | "pivot-bfs" + | "pivot-select" + | "pivot-done" + | "stress-prepare" + | "stress-init" + | "stress-scc" + | "stress-edges" + | "stress-pivots" + | "stress-flow" + | "stress-pack" + | "stress-done"; + +const SEEDER_PHASE_ORDER: readonly SparseStressSolverPhase[] = [ + "setup", + "weak-csr-degree", + "weak-csr-prefix", + "weak-csr-fill", + "components-init", + "components-scan", + "pivot-min-fill", + "pivot-row-fill", + "pivot-bfs", + "pivot-select", + "pivot-done", + "stress-prepare", + "stress-init", + "stress-scc", + "stress-edges", + "stress-pivots", + "stress-flow", + "stress-pack", + "stress-done", +]; + +export interface SparseStressTickBudget { + /** Approximate unit budget. Edges, nodes, and pair relaxations each cost ~1. */ + readonly maxWork?: number; + + /** Optional wall-clock budget for this tick, in milliseconds. */ + readonly maxMs?: number; +} + +export interface SparseStressProgressReport { + /** Same value as SparseStressTickResult.phase, repeated for convenient logging. */ + readonly phase: SparseStressSolverPhase; + + /** Monotonic overall progress in [0, 1]. */ + readonly progress: number; + + /** Progress inside the current coarse phase bucket in [0, 1]. */ + readonly phaseProgress: number; + + /** Ordinal of the current fine-grained phase in SparseStressSolverPhase order. */ + readonly stageIndex: number; + + /** Total number of fine-grained phases. */ + readonly stageCount: number; + + readonly epoch: number; + readonly epochs: number; + + /** Currently completed/active pivot row, depending on phase. */ + readonly pivotIndex: number; + + /** Final pivot count after pivoting, or requested pivot count while pivoting. */ + readonly pivotCount: number; + + /** Number of pivots selected so far, or final selected count after pivoting. */ + readonly selectedPivotCount: number; + + /** Requested pivot count while the pivot phase exists; useful for UI labels. */ + readonly requestedPivotCount: number; +} + +export interface SparseStressTickResult { + readonly done: boolean; + readonly phase: SparseStressSolverPhase; + readonly progress: number; + + /** Progress inside the current coarse phase bucket in [0, 1]. */ + readonly phaseProgress: number; + + readonly workDone: number; + + readonly elapsedMs: number; + + readonly epoch: number; + readonly epochs: number; + + readonly pivotIndex: number; + readonly pivotCount: number; + + /** Structured progress data for logging/debug UI without poking private fields. */ + readonly report: SparseStressProgressReport; + + readonly x: Float32Array; + readonly y: Float32Array; + + readonly result?: SparseStressSolverResult; +} + +const assertNonNegative = (value: number, name: string) => { + if (value < 0) { + throw new Error(`Expected ${name} to be non-negative, got ${value}`); + } + + return value; +}; + +const assertPositive = (value: number, name: string) => { + if (value <= 0) { + throw new Error(`Expected ${name} to be positive, got ${value}`); + } + + return value; +}; + +const validateInput = ({ + n, + src, + dst, + x, + y, +}: SparseStressSolverInput): void => { + if (!Number.isInteger(n) || n < 0) { + throw new Error("n must be a non-negative integer."); + } + + if (src.length !== dst.length) { + throw new Error("src and dst must have the same length."); + } + + if (x && x.length < n) { + throw new Error("x must have length at least n."); + } + + if (y && y.length < n) { + throw new Error("y must have length at least n."); + } +}; + +const defaultPivotCount = (n: number): number => { + if (n <= 1) { + return 0; + } + + if (n < 128) { + return Math.min(n, 16); + } + + return Math.min(n, Math.max(32, Math.min(256, Math.ceil(Math.sqrt(n) * 2)))); +}; + +const allocatePivots = ( + components: WeakComponents, + total: number, +): Uint32Array => { + const cN = components.count; + const alloc = new Uint32Array(cN); + if (total <= 0 || cN === 0) { + return alloc; + } + + const order = new Uint32Array(cN); + for (let c = 0; c < cN; c++) { + order[c] = c; + } + order.sort((a, b) => components.sizes[b]! - components.sizes[a]!); + + let remaining = total; + let active = 0; + + for (const c of order) { + const size = components.sizes[c]; + if (size === 0 || remaining === 0) { + continue; + } + + alloc[c] = 1; + remaining -= 1; + active += 1; + } + + if (remaining === 0) { + return alloc; + } + + let totalActiveSize = 0; + for (const c of order) { + if (alloc[c]! > 0) { + totalActiveSize += components.sizes[c]!; + } + } + if (active === 0 || totalActiveSize === 0) { + return alloc; + } + + for (const c of order) { + if (remaining === 0) { + break; + } + const size = components.sizes[c]!; + if (size <= alloc[c]!) { + continue; + } + + const proportional = Math.floor((total * size) / totalActiveSize); + const target = Math.max(alloc[c]!, proportional); + const add = Math.min( + remaining, + Math.max(0, target - alloc[c]!), + size - alloc[c]!, + ); + + alloc[c]! += add; + remaining -= add; + } + + let cursor = 0; + while (remaining > 0) { + const c = order[cursor % order.length]!; + + if (components.sizes[c]! > alloc[c]!) { + alloc[c]! += 1; + remaining -= 1; + } + + cursor += 1; + + if (cursor > order.length * 2 && remaining > 0) { + let changed = false; + + for (const cc of order) { + if (remaining === 0) { + break; + } + + if (components.sizes[cc]! > alloc[cc]!) { + alloc[cc]! += 1; + remaining -= 1; + changed = true; + } + } + + if (!changed) { + break; + } + } + } + + return alloc; +}; + +const now = () => performance.now(); + +const positiveOr = (value: number | undefined, fallback: number): number => + value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; + +const clampInt = (value: number, lo: number, hi: number): number => { + const v = Math.trunc(value); + if (v < lo) { + return lo; + } + + if (v > hi) { + return hi; + } + return v; +}; + +const clampNumber = (value: number, lo: number, hi: number): number => { + if (!Number.isFinite(value)) { + return lo; + } + if (value < lo) { + return lo; + } + if (value > hi) { + return hi; + } + return value; +}; + +const ratio01 = (num: number, den: number): number => + den <= 0 ? 1 : clampNumber(num / den, 0, 1); + +const mixProgress = (base: number, span: number, inner: number): number => + clampNumber(base + span * clampNumber(inner, 0, 1), 0, 1); + +const hashU32 = (x: number): number => { + x >>>= 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d); + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b); + x ^= x >>> 16; + return x >>> 0; +}; + +const hash01 = (x: number): number => hashU32(x) / 0x100000000; + +const recenterComponents = ( + x: Float32Array, + y: Float32Array, + components: WeakComponents, +): void => { + for (let c = 0; c < components.count; c++) { + const start = components.offsets[c]!; + const end = components.offsets[c + 1]!; + const size = end - start; + if (size === 0) { + continue; + } + + let sx = 0; + let sy = 0; + for (let i = start; i < end; i++) { + const v = components.nodes[i]!; + + sx += x[v]!; + sy += y[v]!; + } + + const cx = sx / size; + const cy = sy / size; + for (let i = start; i < end; i++) { + const v = components.nodes[i]!; + x[v]! -= cx; + y[v]! -= cy; + } + } +}; + +const recenterAll = (x: Float32Array, y: Float32Array, n: number): void => { + if (n === 0) { + return; + } + + let sx = 0; + let sy = 0; + for (let i = 0; i < n; i++) { + sx += x[i]!; + sy += y[i]!; + } + const cx = sx / n; + const cy = sy / n; + for (let i = 0; i < n; i++) { + x[i]! -= cx; + y[i]! -= cy; + } +}; + +const packWeakComponents = ( + x: Float32Array, + y: Float32Array, + components: WeakComponents, + padding: number, +): void => { + const cN = components.count; + if (cN <= 1) { + recenterComponents(x, y, components); + return; + } + + const minX = new Float32Array(cN); + const maxX = new Float32Array(cN); + const minY = new Float32Array(cN); + const maxY = new Float32Array(cN); + + for (let c = 0; c < cN; c++) { + minX[c] = Infinity; + minY[c] = Infinity; + maxX[c] = -Infinity; + maxY[c] = -Infinity; + } + + for (let c = 0; c < cN; c++) { + for (let i = components.offsets[c]!; i < components.offsets[c + 1]!; i++) { + const v = components.nodes[i]!; + const xv = x[v]!; + const yv = y[v]!; + if (xv < minX[c]!) { + minX[c] = xv; + } + if (xv > maxX[c]!) { + maxX[c] = xv; + } + if (yv < minY[c]!) { + minY[c] = yv; + } + if (yv > maxY[c]!) { + maxY[c] = yv; + } + } + } + + const order = new Uint32Array(cN); + let totalArea = 0; + + for (let c = 0; c < cN; c++) { + order[c] = c; + const w = Math.max(padding, maxX[c]! - minX[c]! + 2 * padding); + const h = Math.max(padding, maxY[c]! - minY[c]! + 2 * padding); + totalArea += w * h; + } + + order.sort((a, b) => components.sizes[b]! - components.sizes[a]!); + + const targetRowWidth = Math.max(padding, Math.sqrt(totalArea) * 1.25); + const shiftX = new Float32Array(cN); + const shiftY = new Float32Array(cN); + + let cursorX = 0; + let cursorY = 0; + let rowH = 0; + + for (const c of order) { + const w = Math.max(padding, maxX[c]! - minX[c]! + 2 * padding); + const h = Math.max(padding, maxY[c]! - minY[c]! + 2 * padding); + + if (cursorX > 0 && cursorX + w > targetRowWidth) { + cursorX = 0; + cursorY += rowH; + rowH = 0; + } + + shiftX[c] = cursorX + padding - minX[c]!; + shiftY[c] = cursorY + padding - minY[c]!; + + cursorX += w; + if (h > rowH) { + rowH = h; + } + } + + for (let c = 0; c < cN; c++) { + const sx = shiftX[c]!; + const sy = shiftY[c]!; + + for (let i = components.offsets[c]!; i < components.offsets[c + 1]!; i++) { + const v = components.nodes[i]!; + x[v]! += sx; + y[v]! += sy; + } + } + + recenterAll(x, y, components.nodes.length); +}; + +interface SccResult { + readonly labels: Int32Array; + readonly count: number; + readonly sizes: Uint32Array; +} + +const buildDirectedCsr = ( + n: number, + src: Uint32Array, + dst: Uint32Array, + { + reverse, + validate, + }: { readonly reverse: boolean; readonly validate: boolean }, +): CsrGraph => { + const degree = new Uint32Array(n); + const m = src.length; + + for (let e = 0; e < m; e++) { + const a = reverse ? dst[e]! : src[e]!; + const b = reverse ? src[e]! : dst[e]!; + + if (validate && (a >= n || b >= n)) { + throw new Error(`edge ${e} has a node id outside [0, n).`); + } + + if (a === b) { + continue; + } + + degree[a]! += 1; + } + + const offsets = new Uint32Array(n + 1); + for (let i = 0; i < n; i++) { + offsets[i + 1] = offsets[i]! + degree[i]!; + } + + const targets = new Uint32Array(offsets[n]!); + const cursor = offsets.slice(0, n); + + for (let e = 0; e < m; e++) { + const a = reverse ? dst[e]! : src[e]!; + const b = reverse ? src[e]! : dst[e]!; + if (a === b) { + continue; + } + + targets[cursor[a]!] = b; + cursor[a]! += 1; + } + + return { offsets, targets, degree }; +}; + +/** + * Iterative Kosaraju-Sharir SCC labeling used by the optional flow projection. + * It avoids recursion so it is safe for large browser graphs. + */ +const computeSccLabels = ( + n: number, + src: Uint32Array, + dst: Uint32Array, + { validate }: { readonly validate: boolean }, +): SccResult => { + if (validate) { + if (!Number.isInteger(n) || n < 0) { + throw new Error("n must be a non-negative integer."); + } + if (src.length !== dst.length) { + throw new Error("src and dst must have the same length."); + } + } + + const g = buildDirectedCsr(n, src, dst, { reverse: false, validate }); + const gr = buildDirectedCsr(n, src, dst, { reverse: true, validate }); + + const visited = new Uint8Array(n); + const iter = new Uint32Array(n); + const stack = new Uint32Array(n); + const order = new Uint32Array(n); + let orderLen = 0; + + for (let start = 0; start < n; start++) { + if (visited[start]) { + continue; + } + + let sp = 0; + visited[start] = 1; + iter[start] = g.offsets[start]!; + stack[sp] = start; + sp += 1; + + while (sp > 0) { + const u = stack[sp - 1]!; + let p = iter[u]!; + const end = g.offsets[u + 1]!; + + while (p < end && visited[g.targets[p]!]) { + p += 1; + } + iter[u] = p; + + if (p < end) { + const v = g.targets[p]!; + iter[u] = p + 1; + + if (!visited[v]) { + visited[v] = 1; + iter[v] = g.offsets[v]!; + stack[sp] = v; + sp += 1; + } + } else { + sp -= 1; + order[orderLen] = u; + orderLen += 1; + } + } + } + + const labels = new Int32Array(n); + labels.fill(-1); + const sizes: number[] = []; + let count = 0; + + for (let oi = orderLen - 1; oi >= 0; oi--) { + const start = order[oi]!; + if (labels[start] !== -1) { + continue; + } + + let sp = 0; + let size = 0; + labels[start] = count; + stack[sp] = start; + sp += 1; + + while (sp > 0) { + sp -= 1; + const u = stack[sp]!; + size++; + for (let p = gr.offsets[u]!; p < gr.offsets[u + 1]!; p++) { + const v = gr.targets[p]!; + if (labels[v] === -1) { + labels[v] = count; + stack[sp] = v; + sp += 1; + } + } + } + + sizes.push(size); + count++; + } + + return { labels, count, sizes: Uint32Array.from(sizes) }; +}; + +/** + * One pairwise stress-SGD relaxation. + * For a term w_ij (||x_i - x_j|| - d_ij)^2, move the endpoints symmetrically + * along their current separation vector. The `mu = min(w * eta, 1)` cap and + * the half-step endpoint update follow the SGD formulation in + * Zheng/Pawar/Goodman, "Graph Drawing by Stochastic Gradient Descent". + */ +const relaxPair = ( + x: Float32Array, + y: Float32Array, + i: number, + j: number, + ideal: number, + weight: number, + eta: number, +): void => { + const dx = x[i]! - x[j]!; + const dy = y[i]! - y[j]!; + const len2 = dx * dx + dy * dy + EPS; + const len = Math.sqrt(len2); + + let mu = weight * eta; + if (mu > 1) { + mu = 1; + } + if (mu <= 0) { + return; + } + + const s = (mu * 0.5 * (len - ideal)) / len; + const mx = s * dx; + const my = s * dy; + + x[i]! -= mx; + y[i]! -= my; + x[j]! += mx; + y[j]! += my; +}; + +/** + * Deterministic separation direction (radians) for two coincident nodes. The fused + * overlap term needs a reproducible push when the separation vector is degenerate, + * otherwise stacked nodes never move apart. + */ +const coincidentAngle = (i: number, j: number): number => + hash01((((i + 1) * 0x9e3779b1) ^ ((j + 1) * 0x85ebca6b)) >>> 0) * TAU; + +// Pack signed grid-cell coordinates into a single numeric map key. Coordinate +// collisions (from very large layouts) only ever add extra candidates to a bucket +// that the distance test then discards, so they never cause a missed overlap. +const CELL_KEY_OFFSET = 1 << 15; +const CELL_KEY_STRIDE = 1 << 16; +const cellKey = (cx: number, cy: number): number => + (cx + CELL_KEY_OFFSET) * CELL_KEY_STRIDE + (cy + CELL_KEY_OFFSET); + +/** + * Exponential annealing schedule for stress SGD. + * This follows the schedule shape used by Zheng/Pawar/Goodman: start with an + * eta large enough that low-weight long-distance terms can move, then decay + * toward epsilon so late epochs behave like small local refinements. + */ +const etaAt = ( + epoch: number, + epochs: number, + diameter: number, + epsilon = 0.1, +): number => { + if (epochs <= 1) { + return epsilon; + } + const d = Math.max(1, diameter); + const etaMax = d * d; + const etaMin = Math.max(EPS, epsilon); + return etaMax * Math.exp(Math.log(etaMin / etaMax) * (epoch / (epochs - 1))); +}; + +class CsrPhase { + #n: number; + #validate: boolean; + + #degree: Uint32Array; + #offsets: Uint32Array; + #targets: Uint32Array; + #cursor: Uint32Array; + + #edgeCursor = 0; + #nodeCursor = 0; + #prefixTotal = 0; + + #phase: "degree" | "prefix" | "fill" | "done" = "degree"; + + #result: CsrGraph | undefined; + + constructor(n: number, { validate }: { readonly validate: boolean }) { + this.#n = n; + + this.#degree = new Uint32Array(n); + this.#offsets = new Uint32Array(n + 1); + this.#targets = new Uint32Array(0); + this.#cursor = new Uint32Array(n); + + this.#validate = validate; + } + + #computeDegree(src: Uint32Array, dst: Uint32Array, budget: number) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const edge = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[edge]!; + const v = dst[edge]!; + + if (this.#validate && (u >= this.#n || v >= this.#n)) { + throw new Error(`edge ${edge} has a node id outside [0, n).`); + } + + if (u !== v) { + this.#degree[u]! += 1; + this.#degree[v]! += 1; + } + + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#nodeCursor = 0; + this.#prefixTotal = 0; + this.#phase = "prefix"; + } + + return work; + } + + #computePrefix(budget: number) { + let work = 0; + if (this.#nodeCursor === 0) { + this.#offsets[0] = 0; + } + + while (this.#nodeCursor < this.#n && work < budget) { + this.#prefixTotal += this.#degree[this.#nodeCursor]!; + this.#offsets[this.#nodeCursor + 1] = this.#prefixTotal; + + this.#nodeCursor += 1; + work += 1; + } + + if (this.#nodeCursor >= this.#n) { + this.#targets = new Uint32Array(this.#offsets[this.#n]!); + this.#cursor = this.#offsets.slice(0, this.#n); + + this.#edgeCursor = 0; + this.#phase = "fill"; + } + + return work; + } + + #computeFill(src: Uint32Array, dst: Uint32Array, budget: number) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const edge = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[edge]!; + const v = dst[edge]!; + + if (u !== v) { + this.#targets[this.#cursor[u]!] = v; + this.#targets[this.#cursor[v]!] = u; + + this.#cursor[u]! += 1; + this.#cursor[v]! += 1; + } + + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#phase = "done"; + + this.#result = { + offsets: this.#offsets, + targets: this.#targets, + degree: this.#degree, + }; + } + + return work; + } + + step(src: Uint32Array, dst: Uint32Array, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "degree": + work += this.#computeDegree(src, dst, remaining); + break; + case "prefix": + work += this.#computePrefix(remaining); + break; + case "fill": + work += this.#computeFill(src, dst, remaining); + break; + case "done": + return work; + } + } + + return work; + } + + progress(edgeCount: number): number { + switch (this.#phase) { + case "degree": + return mixProgress(0, 1 / 3, ratio01(this.#edgeCursor, edgeCount)); + case "prefix": + return mixProgress(1 / 3, 1 / 3, ratio01(this.#nodeCursor, this.#n)); + case "fill": + return mixProgress(2 / 3, 1 / 3, ratio01(this.#edgeCursor, edgeCount)); + case "done": + return 1; + } + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +class WeakComponentsPhase { + readonly #n: number; + + readonly #labels: Int32Array; + readonly #queue: Uint32Array; + readonly #nodes: Uint32Array; + + readonly #offsets: Column; + readonly #sizes: Column; + readonly #seeds: Column; + + #nodeCursor = 0; + + #scan = 0; + #count = 0; + #nodeWrite = 0; + #active = false; + #head = 0; + #tail = 0; + #currentU = -1; + #neighborP = 0; + #neighborEnd = 0; + #best = 0; + #bestDegree = 0; + #size = 0; + + #phase: "init" | "scan" | "done" = "init"; + #result: WeakComponents | undefined; + + constructor(n: number) { + this.#n = n; + + this.#labels = new Int32Array(n); + this.#queue = new Uint32Array(n); + this.#nodes = new Uint32Array(n); + + this.#offsets = new Column(Uint32Array, n); + this.#offsets.push(0); + + this.#sizes = new Column(Uint32Array, n); + this.#seeds = new Column(Uint32Array, n); + } + + #computeInit(budget: number) { + let work = 0; + + while (this.#nodeCursor < this.#n && work < budget) { + this.#labels[this.#nodeCursor] = -1; + this.#nodeCursor += 1; + + work += 1; + } + + if (this.#nodeCursor >= this.#n) { + this.#phase = "scan"; + } + + return work; + } + + #computeScan(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + if (!this.#active) { + while ( + this.#scan < this.#n && + this.#labels[this.#scan] !== -1 && + work < budget + ) { + this.#scan += 1; + work += 1; + } + + if (work >= budget) { + break; + } + + if (this.#scan >= this.#n) { + this.#result = new WeakComponents({ + count: this.#count, + labels: this.#labels, + offsets: this.#offsets.subarray().view, + nodes: this.#nodes, + sizes: this.#sizes.subarray().view, + seeds: this.#seeds.subarray().view, + }); + this.#phase = "done"; + + break; + } + + const start = this.#scan; + this.#labels[start] = this.#count; + this.#head = 0; + this.#tail = 1; + this.#queue[0] = start; + this.#best = start; + this.#bestDegree = csr.degree[start]!; + this.#size = 0; + this.#currentU = -1; + this.#active = true; + } + + if (this.#currentU < 0) { + if (this.#head >= this.#tail) { + this.#sizes.push(this.#size); + this.#seeds.push(this.#best); + this.#offsets.push(this.#nodeWrite); + + this.#count += 1; + this.#active = false; + continue; + } + + const u = this.#queue[this.#head]!; + this.#head += 1; + this.#size += 1; + this.#nodes[this.#nodeWrite] = u; + this.#nodeWrite += 1; + + const degree = csr.degree[u]!; + if ( + degree > this.#bestDegree || + (degree === this.#bestDegree && u < this.#best) + ) { + this.#best = u; + this.#bestDegree = degree; + } + + this.#currentU = u; + this.#neighborP = csr.offsets[u]!; + this.#neighborEnd = csr.offsets[u + 1]!; + } + + while (this.#neighborP < this.#neighborEnd && work < budget) { + const v = csr.targets[this.#neighborP]!; + this.#neighborP += 1; + + if (this.#labels[v] === -1) { + this.#labels[v] = this.#count; + + this.#queue[this.#tail] = v; + this.#tail += 1; + } + + work += 1; + } + + if (this.#neighborP >= this.#neighborEnd) { + this.#currentU = -1; + } + } + + return work; + } + + step(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "init": + work += this.#computeInit(remaining); + break; + case "scan": + work += this.#computeScan(csr, remaining); + break; + case "done": + return work; + } + } + + return work; + } + + progress(): number { + switch (this.#phase) { + case "init": + return mixProgress(0, 0.15, ratio01(this.#nodeCursor, this.#n)); + case "scan": + return mixProgress(0.15, 0.85, ratio01(this.#nodeWrite, this.#n)); + case "done": + return 1; + } + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } +} + +class PivotPhase { + readonly #n: number; + readonly #components: WeakComponents; + readonly #randomSeed: number; + + #requestedPivotCount = 0; + + #alloc: Uint32Array; + #pivotsOut: Uint32Array; + #componentsOut: Int32Array; + #distancesOut: Uint16Array; + #minPivotDist: Uint16Array; + #queue: Uint32Array; + + #k = 0; + #diameter = 1; + + #component = 0; + #local = 0; + #want = 0; + #componentStart = 0; + #componentEnd = 0; + #current = 0; + #tieSalt = 0; + #fillCursor = 0; + #rowFillCursor = 0; + #bfsHead = 0; + #bfsTail = 0; + #bfsCurrentU = -1; + #bfsNeighborP = 0; + #bfsNeighborEnd = 0; + #bfsMaxD = 0; + #selectCursor = 0; + #farthest = 0; + #farthestScore = -1; + + #phase: "min-fill" | "row-fill" | "bfs" | "select" | "done" = "min-fill"; + #result: Pivots | undefined; + + constructor( + n: number, + { + components, + count, + randomSeed, + }: { + readonly components: WeakComponents; + readonly count?: number; + readonly randomSeed: number; + }, + ) { + this.#n = n; + this.#components = components; + this.#randomSeed = randomSeed; + this.#requestedPivotCount = clampInt( + count ?? defaultPivotCount(n), + 0, + this.#n, + ); + + this.#alloc = allocatePivots(components, this.#requestedPivotCount); + this.#pivotsOut = new Uint32Array(this.#requestedPivotCount); + this.#componentsOut = new Int32Array(this.#requestedPivotCount); + this.#distancesOut = new Uint16Array(this.#requestedPivotCount * this.#n); + this.#minPivotDist = new Uint16Array(this.#n); + this.#queue = new Uint32Array(this.#n); + this.#k = 0; + this.#diameter = 1; + this.#component = 0; + + if (this.#requestedPivotCount === 0 || n === 0) { + this.#result = Pivots.unit(); + this.#phase = "done"; + } + + this.#prepareNextComponent(); + } + + #finish() { + this.#result = new Pivots({ + pivots: this.#pivotsOut.slice(0, this.#k), + components: this.#componentsOut.slice(0, this.#k), + distances: this.#distancesOut.slice(0, this.#k * this.#n), + diameter: this.#diameter, + }); + + this.#phase = "done"; + } + + #prepareNextComponent() { + while (this.#component < this.#components.count) { + const want = this.#alloc[this.#component]!; + const size = this.#components.sizes[this.#component]!; + + if (want > 0 && size > 0 && this.#k < this.#requestedPivotCount) { + this.#want = want; + this.#local = 0; + this.#componentStart = this.#components.offsets[this.#component]!; + this.#componentEnd = this.#components.offsets[this.#component + 1]!; + this.#current = this.#components.seeds[this.#component]!; + this.#tieSalt = hashU32( + (this.#randomSeed ^ (this.#component * 0x9e3779b9)) >>> 0, + ); + this.#fillCursor = this.#componentStart; + this.#phase = "min-fill"; + return; + } + + this.#component += 1; + } + + this.#finish(); + } + + #startBfsRow() { + this.#pivotsOut[this.#k] = this.#current; + this.#componentsOut[this.#k] = this.#component; + this.#rowFillCursor = 0; + this.#phase = "row-fill"; + } + + #computeMinFill(budget: number): number { + let work = 0; + + while (this.#fillCursor < this.#componentEnd && work < budget) { + const node = this.#components.nodes[this.#fillCursor]!; + this.#fillCursor += 1; + + this.#minPivotDist[node] = INF_DIST; + work += 1; + } + + if (this.#fillCursor >= this.#componentEnd) { + this.#startBfsRow(); + } + + return work; + } + + #computeRowFill(budget: number): number { + const rowBase = this.#k * this.#n; + let work = 0; + + while (this.#rowFillCursor < this.#n && work < budget) { + this.#distancesOut[rowBase + this.#rowFillCursor] = INF_DIST; + this.#rowFillCursor += 1; + + work += 1; + } + + if (this.#rowFillCursor >= this.#n) { + this.#distancesOut[rowBase + this.#current] = 0; + this.#bfsHead = 0; + this.#bfsTail = 1; + this.#queue[0] = this.#current; + this.#bfsCurrentU = -1; + this.#bfsMaxD = 0; + this.#phase = "bfs"; + } + + return work; + } + + #computeBfs(csr: CsrGraph, budget: number): number { + let work = 0; + const rowBase = this.#k * this.#n; + + while (work < budget) { + if (this.#bfsCurrentU < 0) { + if (this.#bfsHead >= this.#bfsTail) { + if (this.#bfsMaxD > this.#diameter) { + this.#diameter = this.#bfsMaxD; + } + + this.#selectCursor = this.#componentStart; + this.#farthest = this.#current; + this.#farthestScore = -1; + this.#tieSalt = hashU32((this.#tieSalt + this.#local + 1) >>> 0); + this.#phase = "select"; + break; + } + + const u = this.#queue[this.#bfsHead]!; + const du = this.#distancesOut[rowBase + u]!; + + this.#bfsHead += 1; + this.#bfsCurrentU = u; + + if (du >= MAX_STORED_DIST) { + this.#bfsNeighborP = 0; + this.#bfsNeighborEnd = 0; + } else { + this.#bfsNeighborP = csr.offsets[u]!; + this.#bfsNeighborEnd = csr.offsets[u + 1]!; + } + } + + const u = this.#bfsCurrentU; + const du = this.#distancesOut[rowBase + u]!; + const nd = du + 1; + + while (this.#bfsNeighborP < this.#bfsNeighborEnd && work < budget) { + const v = csr.targets[this.#bfsNeighborP]!; + this.#bfsNeighborP += 1; + + if (this.#distancesOut[rowBase + v] === INF_DIST) { + this.#distancesOut[rowBase + v] = nd; + if (nd > this.#bfsMaxD) { + this.#bfsMaxD = nd; + } + + this.#queue[this.#bfsTail] = v; + this.#bfsTail += 1; + } + + work += 1; + } + + if (this.#bfsNeighborP >= this.#bfsNeighborEnd) { + this.#bfsCurrentU = -1; + } + } + + return work; + } + + #computeSelect(budget: number): number { + const rowBase = this.#k * this.#n; + let work = 0; + + while (this.#selectCursor < this.#componentEnd && work < budget) { + const v = this.#components.nodes[this.#selectCursor]!; + this.#selectCursor += 1; + + const d = this.#distancesOut[rowBase + v]!; + if (d < this.#minPivotDist[v]!) { + this.#minPivotDist[v] = d; + } + + const md = this.#minPivotDist[v]!; + if (md !== INF_DIST) { + const score = md * 1024 + (hashU32((v ^ this.#tieSalt) >>> 0) & 1023); + if (score > this.#farthestScore) { + this.#farthestScore = score; + this.#farthest = v; + } + } + + work += 1; + } + + if (this.#selectCursor >= this.#componentEnd) { + const previous = this.#current; + this.#k += 1; + this.#local += 1; + + if ( + this.#k >= this.#requestedPivotCount || + this.#local >= this.#want || + this.#farthest === previous || + this.#farthestScore <= 0 + ) { + this.#component += 1; + this.#prepareNextComponent(); + } else { + this.#current = this.#farthest; + this.#startBfsRow(); + } + } + + return work; + } + + step(csr: CsrGraph, budget: number) { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + + switch (this.#phase) { + case "min-fill": + work += this.#computeMinFill(remaining); + break; + case "row-fill": + work += this.#computeRowFill(remaining); + break; + case "bfs": + work += this.#computeBfs(csr, remaining); + break; + case "select": + work += this.#computeSelect(remaining); + break; + case "done": + return work; + } + } + + return work; + } + + progress(): number { + if (this.#requestedPivotCount <= 0) { + return 1; + } + + let rowProgress = 0; + switch (this.#phase) { + case "min-fill": + rowProgress = mixProgress( + 0, + 0.1, + ratio01( + this.#fillCursor - this.#componentStart, + this.#componentEnd - this.#componentStart, + ), + ); + break; + case "row-fill": + rowProgress = mixProgress( + 0.1, + 0.15, + ratio01(this.#rowFillCursor, this.#n), + ); + break; + case "bfs": + rowProgress = mixProgress( + 0.25, + 0.55, + ratio01(this.#bfsHead, Math.max(1, this.#bfsTail)), + ); + break; + case "select": + rowProgress = mixProgress( + 0.8, + 0.2, + ratio01( + this.#selectCursor - this.#componentStart, + this.#componentEnd - this.#componentStart, + ), + ); + break; + case "done": + return 1; + } + + return ratio01(this.#k + rowProgress, this.#requestedPivotCount); + } + + get phase() { + return this.#phase; + } + + get result() { + return this.#result; + } + + get k() { + return this.#k; + } + + get requestedPivotCount() { + return this.#requestedPivotCount; + } +} + +class StressPhase { + readonly #n: number; + readonly #jitter: number; + readonly #idealEdgeLength: number; + readonly #randomSeed: number; + readonly #keepInitialPositions: boolean; + readonly #flow?: DirectedFlowOptions; + readonly #validate: boolean; + /** Eta-annealing horizon and hard upper bound on epochs. */ + readonly #epochs: number; + /** Lower bound before adaptive stopping may fire. */ + readonly #minEpochs: number; + /** Normalized per-epoch movement below which an epoch counts as "settled". */ + readonly #convergenceEpsilon: number; + /** Consecutive settled epochs required to stop early. */ + readonly #convergenceStreak: number; + readonly #epsilon: number; + readonly #pivotsPerEpoch: number | undefined; + readonly #edgeWeight: number; + readonly #shouldPackComponents: boolean; + readonly #componentPadding: number; + + // Fused proximity/overlap term (only active when radii are supplied). + readonly #radii: Float32Array | undefined; + readonly #overlapPadding: number; + readonly #overlapWeight: number; + readonly #maxRadius: number; + + readonly #x: Float32Array; + readonly #y: Float32Array; + + // Snapshot of positions at the start of the current epoch, used to measure + // per-epoch movement for the adaptive stopping criterion. + readonly #prevX: Float32Array; + readonly #prevY: Float32Array; + #settledStreak = 0; + + // Coordinate initialization state. + #initFirst4: Int32Array | undefined; + #initComponent = 0; + #initNodeCursor = 0; + + // Optional directed-flow/SCC state. + #sccLabels: Int32Array | undefined; + + #epoch = 0; + #eta = 0; + #edgeCursor = 0; + #pivotBatchCursor = 0; + #pivotNodeCursor = 0; + #pivotNodeEnd = 0; + #pivotIndex = 0; + #pivotStart = 0; + + #phase: + | "prepare" + | "init" + | "scc" + | "pack" + | "edges" + | "pivots" + | "flow" + | "done" = "prepare"; + + constructor( + n: number, + x: Float32Array, + y: Float32Array, + { + jitter, + idealEdgeLength, + randomSeed, + keepInitialPositions, + validate, + epochs, + minEpochs, + convergenceEpsilon, + convergenceStreak, + epsilon, + pivotsPerEpoch, + edgeWeight, + shouldPackComponents, + componentPadding, + flow, + radii, + overlapPadding, + overlapWeight, + }: { + readonly jitter: number; + readonly idealEdgeLength: number; + readonly randomSeed: number; + readonly keepInitialPositions: boolean; + readonly validate: boolean; + readonly epochs: number; + readonly minEpochs: number; + readonly convergenceEpsilon: number; + readonly convergenceStreak: number; + readonly epsilon: number; + readonly pivotsPerEpoch: number | undefined; + readonly edgeWeight: number; + readonly shouldPackComponents: boolean; + readonly componentPadding: number; + readonly flow?: DirectedFlowOptions; + readonly radii: Float32Array | undefined; + readonly overlapPadding: number; + readonly overlapWeight: number; + }, + ) { + this.#n = n; + this.#x = x; + this.#y = y; + + this.#jitter = jitter; + this.#idealEdgeLength = idealEdgeLength; + this.#randomSeed = randomSeed; + this.#keepInitialPositions = keepInitialPositions; + this.#validate = validate; + this.#epochs = epochs; + this.#minEpochs = minEpochs; + this.#convergenceEpsilon = convergenceEpsilon; + this.#convergenceStreak = convergenceStreak; + this.#epsilon = epsilon; + this.#pivotsPerEpoch = pivotsPerEpoch; + this.#edgeWeight = edgeWeight; + this.#shouldPackComponents = shouldPackComponents; + this.#componentPadding = componentPadding; + this.#flow = flow; + + this.#radii = radii; + this.#overlapPadding = overlapPadding; + this.#overlapWeight = overlapWeight; + + let maxRadius = 0; + if (radii) { + for (let i = 0; i < n; i++) { + if (radii[i]! > maxRadius) { + maxRadius = radii[i]!; + } + } + } + this.#maxRadius = maxRadius; + + this.#prevX = new Float32Array(n); + this.#prevY = new Float32Array(n); + } + + #prepareCoordinates(components: WeakComponents, pivots: Pivots): number { + const first4 = new Int32Array(components.count * 4); + first4.fill(-1); + + for (let p = 0; p < pivots.pivots.length; p++) { + const c = pivots.components[p]!; + const base = c * 4; + + for (let slot = 0; slot < 4; slot++) { + if (first4[base + slot] === -1) { + first4[base + slot] = p; + break; + } + } + } + + this.#initFirst4 = first4; + this.#initComponent = 0; + this.#initNodeCursor = components.count > 0 ? components.offsets[0]! : 0; + this.#phase = "init"; + + return 1; + } + + #finishCoordinates(pivots: Pivots) { + if (this.#flow?.enabled && this.#flow.sccLabels) { + this.#sccLabels = this.#flow.sccLabels; + } + + if ( + this.#flow?.enabled && + !this.#flow.includeIntraScc && + !this.#sccLabels + ) { + this.#phase = "scc"; + } else { + this.#prepareStressOrPack(pivots); + } + } + + #computeCoordinates( + components: WeakComponents, + pivots: Pivots, + budget: number, + ) { + const jitterScale = this.#idealEdgeLength * this.#jitter; + let work = 0; + + const distance = (d: number) => (d === INF_DIST ? 0 : d); + const first4 = this.#initFirst4!; + + while (this.#initComponent < components.count && work < budget) { + const end = components.offsets[this.#initComponent + 1]!; + const size = end - components.offsets[this.#initComponent]!; + const base = this.#initComponent * 4; + + const p0 = first4[base]!; + const p1 = first4[base + 1]!; + const p2 = first4[base + 2]!; + const p3 = first4[base + 3]!; + + while (this.#initNodeCursor < end && work < budget) { + const v = components.nodes[this.#initNodeCursor]!; + + if (this.#keepInitialPositions) { + if (jitterScale > 0) { + this.#x[v]! += + (hash01((v ^ (this.#randomSeed * 0x9e3779b1)) >>> 0) - 0.5) * + jitterScale; + this.#y[v]! += + (hash01(((v + 0x27d4eb2d) ^ this.#randomSeed) >>> 0) - 0.5) * + jitterScale; + } + + this.#initNodeCursor += 1; + work += 1; + continue; + } + + let px = 0; + let py = 0; + + if (p0 >= 0 && p1 >= 0) { + const d0 = pivots.distances[p0 * this.#n + v]!; + const d1 = pivots.distances[p1 * this.#n + v]!; + + px = (distance(d0) - distance(d1)) * this.#idealEdgeLength; + } else if (p0 >= 0) { + const d0 = distance(pivots.distances[p0 * this.#n + v]!); + const angle = hash01((v ^ this.#randomSeed) >>> 0) * TAU; + + px = d0 * this.#idealEdgeLength * Math.cos(angle); + py = d0 * this.#idealEdgeLength * Math.sin(angle); + } else { + const local = + this.#initNodeCursor - components.offsets[this.#initComponent]!; + const angle = hash01((v ^ this.#randomSeed) >>> 0) * TAU; + + const r = Math.sqrt(local + 1) * this.#idealEdgeLength; + + px = r * Math.cos(angle); + py = r * Math.sin(angle); + } + + if (p2 >= 0 && p3 >= 0) { + const d2 = distance(pivots.distances[p2 * this.#n + v]!); + const d3 = distance(pivots.distances[p3 * this.#n + v]!); + + py = (distance(d2) - distance(d3)) * this.#idealEdgeLength; + } else if (p2 >= 0 && p0 >= 0 && p1 >= 0) { + const d0 = distance(pivots.distances[p0 * this.#n + v]!); + const d1 = distance(pivots.distances[p1 * this.#n + v]!); + const d2 = distance(pivots.distances[p2 * this.#n + v]!); + + py = (d2 - 0.5 * (d0 + d1)) * this.#idealEdgeLength; + } else if (p1 >= 0) { + const angle = + hash01(((v + 0x85ebca6b) ^ this.#randomSeed) >>> 0) * TAU; + + py = + Math.sin(angle) * + Math.max( + this.#idealEdgeLength, + Math.sqrt(size) * 0.01 * this.#idealEdgeLength, + ); + } + + if (jitterScale > 0) { + px += + (hash01((v ^ (this.#randomSeed * 0x9e3779b1)) >>> 0) - 0.5) * + jitterScale; + py += + (hash01(((v + 0x27d4eb2d) ^ this.#randomSeed) >>> 0) - 0.5) * + jitterScale; + } + + this.#x[v] = px; + this.#y[v] = py; + this.#initNodeCursor += 1; + work += 1; + } + + if (this.#initNodeCursor >= end) { + this.#initComponent += 1; + this.#initNodeCursor = + this.#initComponent < components.count + ? components.offsets[this.#initComponent]! + : 0; + } + } + + if (this.#initComponent >= components.count) { + this.#finishCoordinates(pivots); + } + + return work; + } + + #computeScc(src: Uint32Array, dst: Uint32Array, pivots: Pivots) { + // SCC computation is only needed for optional directed-flow projection. + // Pass directedFlow.sccLabels if you want to avoid this one-shot pass in a + // tight frame budget. The algorithm itself is iterative Kosaraju-Sharir. + this.#sccLabels = computeSccLabels(this.#n, src, dst, { + validate: this.#validate, + }).labels; + + this.#prepareStressOrPack(pivots); + return 1; + } + + #prepareStressOrPack(pivots: Pivots) { + this.#epoch = 0; + + if (this.#epochs <= 0) { + this.#phase = "pack"; + return; + } + + this.#beginEpoch(pivots); + } + + #resolvedPivotsPerEpoch(pivots: Pivots): number { + const k = pivots.pivots.length; + return clampInt(this.#pivotsPerEpoch ?? k, 0, k); + } + + #prepareNextPivot(components: WeakComponents, pivots: Pivots) { + const k = pivots.pivots.length; + const limit = this.#resolvedPivotsPerEpoch(pivots); + + if (k === 0 || limit <= 0 || this.#pivotBatchCursor >= limit) { + this.#phase = "flow"; + this.#edgeCursor = 0; + return; + } + + const pIndex = (this.#pivotStart + this.#pivotBatchCursor) % k; + const component = pivots.components[pIndex]!; + + this.#pivotIndex = pIndex; + this.#pivotNodeCursor = components.offsets[component]!; + this.#pivotNodeEnd = components.offsets[component + 1]!; + } + + #beginEpoch(pivots: Pivots) { + this.#eta = etaAt( + this.#epoch, + Math.max(1, this.#epochs), + Math.max(1, pivots.diameter), + this.#epsilon, + ); + // Remember where every node started this epoch so the adaptive stop can + // measure how far the farthest-moving node travelled by the epoch's end. + this.#prevX.set(this.#x); + this.#prevY.set(this.#y); + this.#edgeCursor = 0; + this.#pivotBatchCursor = 0; + this.#pivotNodeCursor = 0; + this.#pivotNodeEnd = 0; + this.#pivotIndex = 0; + this.#pivotStart = + pivots.pivots.length === 0 + ? 0 + : (this.#epoch * this.#resolvedPivotsPerEpoch(pivots)) % + pivots.pivots.length; + this.#phase = "edges"; + } + + #computeEdges( + src: Uint32Array, + dst: Uint32Array, + components: WeakComponents, + pivots: Pivots, + budget: number, + ) { + const m = src.length; + let work = 0; + + while (this.#edgeCursor < m && work < budget) { + const e = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[e]!; + const v = dst[e]!; + + if (u !== v) { + relaxPair( + this.#x, + this.#y, + u, + v, + this.#idealEdgeLength, + this.#edgeWeight, + this.#eta, + ); + } + work += 1; + } + + if (this.#edgeCursor >= m) { + this.#phase = "pivots"; + this.#prepareNextPivot(components, pivots); + } + + return work; + } + + #computePivots(components: WeakComponents, pivots: Pivots, budget: number) { + const distances = pivots.distances; + let work = 0; + + while (work < budget) { + const limit = this.#resolvedPivotsPerEpoch(pivots); + if ( + pivots.pivots.length === 0 || + limit <= 0 || + this.#pivotBatchCursor >= limit + ) { + this.#phase = "flow"; + this.#edgeCursor = 0; + break; + } + + const pivot = pivots.pivots[this.#pivotIndex]!; + const rowBase = this.#pivotIndex * this.#n; + + while (this.#pivotNodeCursor < this.#pivotNodeEnd && work < budget) { + const v = components.nodes[this.#pivotNodeCursor]!; + this.#pivotNodeCursor += 1; + const d = distances[rowBase + v]!; + + if (d !== 0 && d !== INF_DIST) { + const ideal = d * this.#idealEdgeLength; + const weight = 1 / (d * d); + + relaxPair(this.#x, this.#y, pivot, v, ideal, weight, this.#eta); + } + work++; + } + + if (this.#pivotNodeCursor >= this.#pivotNodeEnd) { + this.#pivotBatchCursor += 1; + this.#prepareNextPivot(components, pivots); + if (this.#phase !== "pivots") { + break; + } + } + } + + return work; + } + + #computeFlow( + src: Uint32Array, + dst: Uint32Array, + components: WeakComponents, + pivots: Pivots, + budget: number, + ) { + const flow = this.#flow; + const m = src.length; + let work = 0; + + if (flow?.enabled) { + const every = Math.max(1, flow.every ?? 1); + if (this.#epoch % every === 0) { + const separation = + this.#idealEdgeLength * positiveOr(flow.separation, 1); + const alpha = clampNumber(flow.alpha ?? 0.08, 0, 1); + const includeIntraScc = flow.includeIntraScc === true; + const labels = this.#sccLabels; + + while (this.#edgeCursor < m && work < budget) { + const e = this.#edgeCursor; + this.#edgeCursor += 1; + + const u = src[e]!; + const v = dst[e]!; + + if ( + u !== v && + (includeIntraScc || !labels || labels[u] !== labels[v]) + ) { + const gap = this.#y[v]! - this.#y[u]!; + const violation = separation - gap; + if (violation > 0) { + const move = 0.5 * alpha * violation; + this.#y[u]! -= move; + this.#y[v]! += move; + } + } + work++; + } + } else { + this.#edgeCursor = m; + } + } else { + this.#edgeCursor = m; + } + + if (this.#edgeCursor >= m) { + // Fused proximity/overlap term: one eta-scaled pass at the end of every epoch. + // Because it uses the *current* epoch's eta and runs inside the SGD loop, + // overlap resolution and stress minimization anneal together and re-tighten + // each other. A terminal-only overlap pass (eta ~ 0, no edge tension left) + // wrecks edge fidelity, which is exactly what this avoids. + if (this.#radii) { + work += this.#relaxOverlaps(components); + } + + this.#epoch += 1; + + // Adaptive stopping: once the farthest-moving node barely moves for a few + // epochs in a row, eta has annealed to near-0 and further epochs are no-ops, + // so we stop instead of grinding through a fixed horizon. `#minEpochs` guards + // against the high-eta opening epochs tripping this early. + const settled = this.#maxDisplacement() < this.#convergenceEpsilon; + this.#settledStreak = settled ? this.#settledStreak + 1 : 0; + const converged = + this.#epoch >= this.#minEpochs && + this.#settledStreak >= this.#convergenceStreak; + + if (converged || this.#epoch >= this.#epochs) { + this.#phase = "pack"; + } else { + this.#beginEpoch(pivots); + } + } + + return work; + } + + /** + * Largest per-node displacement since the start of the current epoch, expressed in + * ideal-edge-length units so the adaptive-stop tolerance is scale-independent. + */ + #maxDisplacement(): number { + const px = this.#prevX; + const py = this.#prevY; + let maxSq = 0; + for (let i = 0; i < this.#n; i++) { + const dx = this.#x[i]! - px[i]!; + const dy = this.#y[i]! - py[i]!; + const sq = dx * dx + dy * dy; + if (sq > maxSq) { + maxSq = sq; + } + } + return Math.sqrt(maxSq) / Math.max(EPS, this.#idealEdgeLength); + } + + /** + * One pass of the fused proximity/overlap term (PRISM-style overlap-as-stress). + * For every same-component node pair whose disks currently overlap, apply a + * one-sided, eta-scaled stress relaxation toward the target gap `r_i + r_j + pad`. + * A uniform grid restricts the work to spatial neighbours, so the pass is O(n) + * for bounded local density. Deterministic: nodes are bucketed and scanned in + * index order and each unordered pair is visited once. + */ + #relaxOverlaps(components: WeakComponents): number { + const n = this.#n; + const radii = this.#radii; + if (!radii || n < 2) { + return 0; + } + + const eta = this.#eta; + let mu = this.#overlapWeight * eta; + if (mu > 1) { + mu = 1; + } + if (mu <= 0) { + return 0; + } + + const x = this.#x; + const y = this.#y; + const labels = components.labels; + const padding = this.#overlapPadding; + const cell = Math.max(EPS, 2 * this.#maxRadius + padding); + const invCell = 1 / cell; + + // Bucket nodes into grid cells (insertion = index order keeps this deterministic). + const cellOf = new Int32Array(n * 2); + const buckets = new Map(); + for (let i = 0; i < n; i++) { + const cx = Math.floor(x[i]! * invCell); + const cy = Math.floor(y[i]! * invCell); + cellOf[i * 2] = cx; + cellOf[i * 2 + 1] = cy; + const key = cellKey(cx, cy); + const bucket = buckets.get(key); + if (bucket) { + bucket.push(i); + } else { + buckets.set(key, [i]); + } + } + + let work = 0; + for (let a = 0; a < n; a++) { + const ax = x[a]!; + const ay = y[a]!; + const ra = radii[a]!; + const la = labels[a]!; + const acx = cellOf[a * 2]!; + const acy = cellOf[a * 2 + 1]!; + + for (let ox = -1; ox <= 1; ox++) { + for (let oy = -1; oy <= 1; oy++) { + const bucket = buckets.get(cellKey(acx + ox, acy + oy)); + if (!bucket) { + continue; + } + + for (let bi = 0; bi < bucket.length; bi++) { + const b = bucket[bi]!; + // Visit each unordered pair once and only separate within a component; + // cross-component spacing is handled later by component packing. + if (b <= a || labels[b]! !== la) { + continue; + } + + const target = ra + radii[b]! + padding; + let dx = x[b]! - ax; + let dy = y[b]! - ay; + let dist = Math.sqrt(dx * dx + dy * dy); + + if (dist >= target) { + continue; + } + + work += 1; + + if (dist < EPS) { + // Degenerate separation vector: pick a deterministic direction. + const angle = coincidentAngle(a, b); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = EPS; + } else { + dx /= dist; + dy /= dist; + } + + // One-sided relaxation: push both endpoints apart toward `target`. + const shift = mu * 0.5 * (target - dist); + const sx = dx * shift; + const sy = dy * shift; + x[a]! -= sx; + y[a]! -= sy; + x[b]! += sx; + y[b]! += sy; + } + } + } + } + + return work + n; + } + + #computePack(components: WeakComponents) { + if (this.#shouldPackComponents) { + packWeakComponents( + this.#x, + this.#y, + components, + this.#idealEdgeLength * this.#componentPadding, + ); + } else { + recenterAll(this.#x, this.#y, this.#n); + } + + this.#phase = "done"; + return 1; + } + + step( + src: Uint32Array, + dst: Uint32Array, + components: WeakComponents, + pivots: Pivots, + budget: number, + ): number { + let work = 0; + + while (work < budget) { + const remaining = budget - work; + switch (this.#phase) { + case "prepare": { + work += this.#prepareCoordinates(components, pivots); + break; + } + case "init": { + work += this.#computeCoordinates(components, pivots, remaining); + break; + } + case "scc": { + work += this.#computeScc(src, dst, pivots); + break; + } + case "pack": { + work += this.#computePack(components); + break; + } + case "edges": { + work += this.#computeEdges(src, dst, components, pivots, remaining); + break; + } + case "pivots": { + work += this.#computePivots(components, pivots, remaining); + break; + } + case "flow": { + work += this.#computeFlow(src, dst, components, pivots, remaining); + break; + } + case "done": { + return work; + } + } + } + + return work; + } + + progress( + components: WeakComponents, + pivots: Pivots, + edgeCount: number, + ): number { + switch (this.#phase) { + case "prepare": + return 0; + case "init": + return mixProgress( + 0, + 0.08, + ratio01(this.#initNodeCursor, components.nodes.length), + ); + case "scc": + return 0.09; + case "pack": + return 0.98; + case "done": + return 1; + case "edges": + case "pivots": + case "flow": { + if (this.#epochs <= 0) { + return 0.98; + } + + let inEpoch = 0; + if (this.#phase === "edges") { + inEpoch = mixProgress(0, 0.35, ratio01(this.#edgeCursor, edgeCount)); + } else if (this.#phase === "pivots") { + const limit = this.#resolvedPivotsPerEpoch(pivots); + let nodeProgress = 0; + if (limit > 0 && pivots.pivots.length > 0) { + const component = pivots.components[this.#pivotIndex]!; + const start = components.offsets[component] ?? 0; + const end = components.offsets[component + 1] ?? start; + nodeProgress = ratio01(this.#pivotNodeCursor - start, end - start); + } + const pivotProgress = ratio01( + this.#pivotBatchCursor + nodeProgress, + Math.max(1, limit), + ); + inEpoch = mixProgress(0.35, 0.55, pivotProgress); + } else { + inEpoch = mixProgress(0.9, 0.1, ratio01(this.#edgeCursor, edgeCount)); + } + + return mixProgress( + 0.08, + 0.9, + ratio01(this.#epoch + inEpoch, this.#epochs), + ); + } + } + } + + get phase() { + return this.#phase; + } + + get epoch() { + return this.#epoch; + } + + get epochs() { + return this.#epochs; + } + + get currentPivotIndex() { + return this.#pivotIndex; + } +} + +export class SparseStressSolver { + readonly #n: number; + readonly #src: Uint32Array; + readonly #dst: Uint32Array; + readonly #options: SparseStressSolverOptions; + readonly #validate: boolean; + + readonly #randomSeed: number; + + #phase: SparseStressSolverPhase = "setup"; + #done = false; + #result: SparseStressSolverResult | undefined; + #lastProgress = 0; + + // Lightweight snapshots used after heavy phase objects have been released. + // In particular, PivotPhase owns a full pivot-distance scratch matrix, so it + // should not be kept solely for UI reporting once pivoting is complete. + #requestedPivotCountForReport = 0; + #selectedPivotCountForReport = 0; + + startedAt = 0; + + #x: Float32Array; + #y: Float32Array; + + #components: WeakComponents | undefined; + #csr: CsrGraph | undefined; + #pivots: Pivots | undefined; + + #csrPhase: CsrPhase | undefined; + #componentsPhase: WeakComponentsPhase | undefined; + #pivotPhase: PivotPhase | undefined; + + #stress: StressPhase; + + constructor( + input: SparseStressSolverInput, + options: SparseStressSolverOptions = {}, + ) { + this.#n = input.n; + this.#src = input.src; + this.#dst = input.dst; + this.#options = options; + this.#validate = options.validate ?? true; + + const idealEdgeLength = assertNonNegative( + options.idealEdgeLength ?? 1, + "idealEdgeLength", + ); + const edgeWeight = assertNonNegative(options.edgeWeight ?? 1, "edgeWeight"); + const randomSeed = options.randomSeed ?? 1; + const jitter = options.jitter ?? 0.01; + const epsilon = assertNonNegative(options.epsilon ?? 0.1, "epsilon"); + const shouldPackComponents = options.packComponents ?? true; + const componentPadding = assertNonNegative( + options.componentPadding ?? 4, + "componentPadding", + ); + + this.#x = input.x ?? new Float32Array(input.n); + this.#y = input.y ?? new Float32Array(input.n); + this.#randomSeed = randomSeed; + + // Epoch policy. With an explicit `epochs` we run a fixed schedule (adaptive stop + // disabled). Otherwise the eta schedule anneals over `maxEpochs` while the solver + // stops early once movement settles between `minEpochs` and `maxEpochs`. + const hasFixedEpochs = options.epochs !== undefined; + const autoDisabled = this.#n <= 1 || this.#src.length === 0; + const epochs = hasFixedEpochs + ? clampInt(options.epochs ?? 0, 0, 1000000) + : autoDisabled + ? 0 + : clampInt(options.maxEpochs ?? 60, 0, 1000000); + const minEpochs = hasFixedEpochs + ? epochs + : clampInt(options.minEpochs ?? 8, 0, epochs); + const convergenceEpsilon = assertNonNegative( + options.convergenceEpsilon ?? 3e-3, + "convergenceEpsilon", + ); + const convergenceStreak = clampInt( + options.convergenceStreak ?? 3, + 1, + 1000000, + ); + + const radii = input.radii; + if (this.#validate && radii && radii.length < this.#n) { + throw new Error("radii length must be >= n."); + } + const overlapPadding = assertNonNegative( + options.overlapPadding ?? 1, + "overlapPadding", + ); + const overlapWeight = assertNonNegative( + options.overlapWeight ?? 1, + "overlapWeight", + ); + + this.#stress = new StressPhase(this.#n, this.#x, this.#y, { + jitter, + idealEdgeLength, + randomSeed, + keepInitialPositions: options.keepInitialPositions ?? false, + validate: this.#validate, + epochs, + minEpochs, + convergenceEpsilon, + convergenceStreak, + epsilon, + pivotsPerEpoch: options.pivotsPerEpoch, + edgeWeight, + shouldPackComponents, + componentPadding, + flow: options.directedFlow, + radii, + overlapPadding, + overlapWeight, + }); + } + + #finish(components: WeakComponents, pivots: Pivots, epochs: number): void { + const elapsedMs = this.startedAt === 0 ? 0 : now() - this.startedAt; + const resultPivots = this.#options.returnPivotDistances + ? pivots + : new Pivots({ + pivots: pivots.pivots, + components: pivots.components, + distances: new Uint16Array(0), + diameter: pivots.diameter, + }); + + const result: SparseStressSolverResult = { + x: this.#x, + y: this.#y, + pivots: resultPivots, + components, + epochs, + elapsed: elapsedMs, + }; + + this.#result = result; + this.#phase = "stress-done"; + this.#done = true; + } + + #setup(): number { + if (this.#validate) { + validateInput({ + n: this.#n, + src: this.#src, + dst: this.#dst, + x: this.#x, + y: this.#y, + }); + } + + if (this.#n === 0) { + this.#components = WeakComponents.empty(); + this.#pivots = Pivots.unit(); + this.#requestedPivotCountForReport = 0; + this.#selectedPivotCountForReport = 0; + + this.#finish(this.#components, this.#pivots, 0); + return 0; + } + + this.#csrPhase = new CsrPhase(this.#n, { validate: this.#validate }); + this.#phase = "weak-csr-degree"; + + return 0; + } + + #step(budget: number): number { + switch (this.#phase) { + case "setup": + return this.#setup(); + case "weak-csr-degree": + case "weak-csr-prefix": + case "weak-csr-fill": { + const phase = this.#csrPhase!; + const done = phase.step(this.#src, this.#dst, budget); + + if (phase.phase === "done") { + // Flush the result back to our main class, `done` means that the result has been populated. + this.#csr = phase.result!; + + this.#componentsPhase = new WeakComponentsPhase(this.#n); + + // Keep the completed CSR phase mounted: it is lightweight report state + // and shares the CSR arrays with #csr rather than duplicating them. + this.#phase = "components-init"; + } else { + this.#phase = `weak-csr-${phase.phase}`; + } + + return done; + } + + case "components-init": + case "components-scan": { + const phase = this.#componentsPhase!; + const done = phase.step(this.#csr!, budget); + + if (phase.phase === "done") { + // Flush the result back to our main class, `done` means that the result has been populated. + this.#components = phase.result!; + + this.#pivotPhase = new PivotPhase(this.#n, { + randomSeed: this.#randomSeed, + components: this.#components, + count: this.#options.pivotCount, + }); + this.#requestedPivotCountForReport = + this.#pivotPhase.requestedPivotCount; + // Keep the completed components phase mounted for reporting. It owns a + // queue, which is fine to keep around. + this.#phase = `pivot-${this.#pivotPhase.phase}`; + } else { + this.#phase = `components-${phase.phase}`; + } + + return done; + } + case "pivot-min-fill": + case "pivot-row-fill": + case "pivot-bfs": + case "pivot-select": + case "pivot-done": { + const phase = this.#pivotPhase!; + const done = phase.step(this.#csr!, budget); + + if (phase.phase === "done") { + // Flush the result back to our main class, `done` means that the result has been populated. + this.#pivots = phase.result!; + this.#selectedPivotCountForReport = this.#pivots.pivots.length; + this.#requestedPivotCountForReport = phase.requestedPivotCount; + + this.#phase = `stress-prepare`; + // We unmount the pivot phase, and put it's state used for reporting into a locals, + // reason being that pivoting allocates relatively speaking large scratch buffers. + // That are best dropped early. + this.#pivotPhase = undefined; + } else { + this.#phase = `pivot-${phase.phase}`; + } + + return done; + } + case "stress-prepare": + case "stress-init": + case "stress-scc": + case "stress-edges": + case "stress-pivots": + case "stress-flow": + case "stress-pack": + case "stress-done": { + const previousPhase = this.#phase; + const done = this.#stress.step( + this.#src, + this.#dst, + this.#components!, + this.#pivots!, + budget, + ); + + this.#phase = `stress-${this.#stress.phase}`; + if (this.#phase === "stress-done" && previousPhase !== "stress-done") { + // Report the epochs actually performed (adaptive stopping may end well + // before the `maxEpochs` horizon), not the schedule horizon. + this.#finish(this.#components!, this.#pivots!, this.#stress.epoch); + } + return done; + } + } + } + + #rawProgressEstimate(): number { + if (this.#done) { + return 1; + } + + switch (this.#phase) { + case "setup": + return 0; + case "weak-csr-degree": + case "weak-csr-prefix": + case "weak-csr-fill": + return mixProgress( + 0, + 0.18, + this.#csrPhase?.progress(this.#src.length) ?? 0, + ); + case "components-init": + case "components-scan": + return mixProgress(0.18, 0.17, this.#componentsPhase?.progress() ?? 0); + case "pivot-min-fill": + case "pivot-row-fill": + case "pivot-bfs": + case "pivot-select": + case "pivot-done": + return mixProgress(0.35, 0.3, this.#pivotPhase?.progress() ?? 0); + case "stress-prepare": + case "stress-init": + case "stress-scc": + case "stress-edges": + case "stress-pivots": + case "stress-flow": + case "stress-pack": + case "stress-done": + return mixProgress( + 0.65, + 0.35, + this.#stress.progress( + this.#components ?? WeakComponents.empty(), + this.#pivots ?? Pivots.unit(), + this.#src.length, + ), + ); + } + } + + #progressEstimate(): number { + const progress = this.#rawProgressEstimate(); + this.#lastProgress = Math.max( + this.#lastProgress, + clampNumber(progress, 0, this.#done ? 1 : 0.999), + ); + if (this.#done) { + this.#lastProgress = 1; + } + return this.#lastProgress; + } + + #phaseProgressEstimate(): number { + switch (this.#phase) { + case "setup": + return 0; + case "weak-csr-degree": + case "weak-csr-prefix": + case "weak-csr-fill": + return this.#csrPhase?.progress(this.#src.length) ?? 1; + case "components-init": + case "components-scan": + return this.#componentsPhase?.progress() ?? 1; + case "pivot-min-fill": + case "pivot-row-fill": + case "pivot-bfs": + case "pivot-select": + case "pivot-done": + return this.#pivotPhase?.progress() ?? 1; + case "stress-prepare": + case "stress-init": + case "stress-scc": + case "stress-edges": + case "stress-pivots": + case "stress-flow": + case "stress-pack": + case "stress-done": + return this.#stress.progress( + this.#components ?? WeakComponents.empty(), + this.#pivots ?? Pivots.unit(), + this.#src.length, + ); + } + } + + #pivotCountForReport(): number { + if (this.#pivots) { + return this.#pivots.pivots.length; + } + + if (this.#pivotPhase) { + return this.#pivotPhase.requestedPivotCount; + } + + return this.#requestedPivotCountForReport; + } + + #selectedPivotCountForReportValue(): number { + if (this.#pivotPhase) { + return this.#pivotPhase.k; + } + + return ( + (this.#selectedPivotCountForReport || this.#pivots?.pivots.length) ?? 0 + ); + } + + #stageIndexForReport(): number { + const index = SEEDER_PHASE_ORDER.indexOf(this.#phase); + return index === -1 ? 0 : index; + } + + #currentPivotIndexForReport(): number { + if (this.#pivotPhase) { + return this.#pivotPhase.k; + } + + if (this.#phase.startsWith("stress-")) { + return this.#stress.currentPivotIndex; + } + + return this.#selectedPivotCountForReportValue(); + } + + tick(budget: SparseStressTickBudget = {}): SparseStressTickResult { + const maxWork = assertPositive( + Math.floor(budget.maxWork ?? 50_000), + "maxWork", + ); + const maxMs = assertNonNegative(budget.maxMs ?? Infinity, "maxMs"); + + const start = now(); + if (this.startedAt === 0) { + this.startedAt = start; + } + + let workDone = 0; + let zeroWorkTransitions = 0; + + while (!this.#done && workDone < maxWork) { + // We need to make sure that we have done _some_ work + if (workDone > 0 && now() - start >= maxMs) { + break; + } + + const beforePhase = this.#phase; + const did = this.#step(maxWork - workDone); + + if (did > 0) { + workDone += did; + zeroWorkTransitions = 0; + } else { + zeroWorkTransitions++; + if (beforePhase === this.#phase || zeroWorkTransitions > 64) { + break; + } + } + } + + const elapsed = now() - start; + const progress = this.#progressEstimate(); + const phaseProgress = clampNumber(this.#phaseProgressEstimate(), 0, 1); + const pivotIndex = this.#currentPivotIndexForReport(); + const pivotCount = this.#pivotCountForReport(); + const selectedPivotCount = this.#selectedPivotCountForReportValue(); + const stageIndex = this.#stageIndexForReport(); + + const report: SparseStressProgressReport = { + phase: this.#phase, + progress, + phaseProgress, + stageIndex, + stageCount: SEEDER_PHASE_ORDER.length, + epoch: this.#stress.epoch, + epochs: this.#stress.epochs, + pivotIndex, + pivotCount, + selectedPivotCount, + requestedPivotCount: this.#requestedPivotCountForReport, + }; + + return { + done: this.#done, + phase: this.#phase, + progress, + phaseProgress, + workDone, + elapsedMs: elapsed, + epoch: this.#stress.epoch, + epochs: this.#stress.epochs, + pivotIndex, + pivotCount, + report, + x: this.#x, + y: this.#y, + result: this.#result, + }; + } + + run(): SparseStressSolverResult { + if (this.startedAt === 0) { + this.startedAt = now(); + } + + let zeroWorkTransitions = 0; + while (!this.#done) { + const beforePhase = this.#phase; + const did = this.#step(Infinity); + + if (did > 0) { + zeroWorkTransitions = 0; + } else { + zeroWorkTransitions += 1; + if (beforePhase === this.#phase || zeroWorkTransitions > 64) { + throw new Error( + `SparseStressSolver stalled in phase ${this.#phase}.`, + ); + } + } + } + + return this.#result!; + } + + get result(): SparseStressSolverResult | undefined { + return this.#result; + } + + get phase(): SparseStressSolverPhase { + return this.#phase; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts new file mode 100644 index 00000000000..950b3693a7a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts @@ -0,0 +1,240 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { + FLAT_HEADER_BYTES, + FLAT_RECORD_BYTES, + FlatGraphBuffer, +} from "../buffers/position-buffer"; +import { createStressLayout } from "./stress-layout"; + +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +function settle(layout: LayoutSimulation): void { + for (let step = 0; step < 4000 && !layout.isSettled; step++) { + layout.tick(50); + } +} + +function positionsOf(layout: LayoutSimulation): [number, number][] { + return layout.nodes.map((node) => [node.x ?? 0, node.y ?? 0]); +} + +function makeNodes(count: number): ForceNode[] { + const nodes: ForceNode[] = []; + for (let idx = 0; idx < count; idx++) { + nodes.push({ id: String(idx), x: idx * 7, y: (idx % 3) * 5, radius: 4 }); + } + return nodes; +} + +function layoutOf(nodes: ForceNode[], edges: ForceEdge[]): LayoutSimulation { + return createStressLayout(nodes, edges, new FlatGraphBuffer(nodes.length)); +} + +function distanceBetween( + positions: readonly [number, number][], + lhs: number, + rhs: number, +): number { + return Math.hypot( + positions[lhs]![0] - positions[rhs]![0], + positions[lhs]![1] - positions[rhs]![1], + ); +} + +describe("createStressLayout", () => { + it("settles, stays finite, and re-centres on the origin", () => { + const nodes = makeNodes(8); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + const positions = positionsOf(layout); + let sumX = 0; + let sumY = 0; + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + sumX += posX; + sumY += posY; + } + expect(Math.abs(sumX / positions.length)).toBeLessThan(1); + expect(Math.abs(sumY / positions.length)).toBeLessThan(1); + }); + + it("spreads nodes out (fused overlap term) rather than piling them", () => { + const nodes = makeNodes(12); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "4", target: "5", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + for (let lhs = 0; lhs < positions.length; lhs++) { + for (let rhs = lhs + 1; rhs < positions.length; rhs++) { + // Radius 4 each → centres must not be coincident (overlap relaxation spacing). + expect(distanceBetween(positions, lhs, rhs)).toBeGreaterThan(4); + } + } + }); + + it("pulls a linked pair closer than the graph's overall spread", () => { + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const positions = positionsOf(layout); + expect(distanceBetween(positions, 0, 1)).toBeLessThan( + distanceBetween(positions, 0, 4), + ); + }); + + it("detects Louvain communities (two cliques joined by a bridge → 2)", () => { + const nodes = makeNodes(6); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "0", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + { source: "4", target: "5", weight: 1 }, + { source: "3", target: "5", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const communities = layout.communities!; + expect(communities).toHaveLength(6); + expect(new Set(communities).size).toBe(2); + expect(communities[0]).toBe(communities[1]); + expect(communities[0]).toBe(communities[2]); + expect(communities[3]).toBe(communities[4]); + expect(communities[3]).toBe(communities[5]); + expect(communities[0]).not.toBe(communities[3]); + }); + + it("is deterministic for identical inputs (seeded SGD + overlap)", () => { + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const first = layoutOf(makeNodes(4), [...edges]); + const second = layoutOf(makeNodes(4), [...edges]); + settle(first); + settle(second); + + const firstPositions = positionsOf(first); + const secondPositions = positionsOf(second); + for (let idx = 0; idx < firstPositions.length; idx++) { + expect(firstPositions[idx]![0]).toBeCloseTo(secondPositions[idx]![0], 2); + expect(firstPositions[idx]![1]).toBeCloseTo(secondPositions[idx]![1], 2); + } + }); + + it("warm-absorbs a new node in place (incremental, no restart)", () => { + const nodes = makeNodes(5); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + expect(layout.nodes.length).toBe(5); + + const newNode: ForceNode = { id: "5", x: 0, y: 0, radius: 4 }; + layout.absorb!( + [newNode], + [...edges, { source: "0", target: "5", weight: 1 }], + ); + expect(layout.isSettled).toBe(false); + + settle(layout); + expect(layout.nodes.length).toBe(6); + expect(layout.communities!).toHaveLength(6); + + const positions = positionsOf(layout); + for (const [posX, posY] of positions) { + expect(Number.isFinite(posX)).toBe(true); + expect(Number.isFinite(posY)).toBe(true); + } + const indexOfId = (id: string): number => layout.nodeIds.indexOf(id); + expect( + distanceBetween(positions, indexOfId("5"), indexOfId("0")), + ).toBeLessThan(distanceBetween(positions, indexOfId("5"), indexOfId("3"))); + }); + + it("grows the SAB without losing state (ensureCapacity + absorb)", () => { + const nodes = makeNodes(4); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "2", target: "3", weight: 1 }, + ]; + const buffer = new FlatGraphBuffer(4, () => {}); + const layout = createStressLayout(nodes, edges, buffer); + settle(layout); + + buffer.ensureCapacity(8); + layout.absorb!( + [{ id: "4", x: 0, y: 0, radius: 4 }], + [...edges, { source: "0", target: "4", weight: 1 }], + ); + settle(layout); + + expect(layout.nodes.length).toBe(5); + const view = new Float32Array(buffer.raw, FLAT_HEADER_BYTES); + const slots = FLAT_RECORD_BYTES / 4; + const node4 = layout.nodes[4]!; + expect(view[4 * slots]).toBeCloseTo(node4.x ?? 0, 3); + expect(view[4 * slots + 1]).toBeCloseTo(node4.y ?? 0, 3); + }); + + it("re-globalises (refreshes Louvain communities) after significant growth", () => { + const nodes = makeNodes(6); + const edges: ForceEdge[] = [ + { source: "0", target: "1", weight: 1 }, + { source: "1", target: "2", weight: 1 }, + { source: "3", target: "4", weight: 1 }, + ]; + const layout = layoutOf(nodes, edges); + settle(layout); + + const newNodes: ForceNode[] = []; + const grownEdges: ForceEdge[] = [...edges]; + for (let id = 6; id < 36; id++) { + newNodes.push({ id: String(id), x: 0, y: 0, radius: 4 }); + grownEdges.push({ + source: String(id), + target: String(id % 6), + weight: 1, + }); + } + layout.absorb!(newNodes, grownEdges); + settle(layout); + + expect(layout.nodes.length).toBe(36); + const communities = layout.communities!; + expect(communities).toHaveLength(36); + expect(communities.every((community) => community >= 0)).toBe(true); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts new file mode 100644 index 00000000000..7bc66fa3650 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts @@ -0,0 +1,466 @@ +/** + * The stress-solver community tier: an experimental alternative engine for the + * medium-scale individual-entity regime, drop-in interchangeable with the + * ForceAtlas2 {@link "./community-layout"} engine (both implement + * {@link LayoutSimulation} and fill the same {@link FlatGraphBuffer}). + * + * Where the FA2 engine uses sparse stress only as a coarse *seed* and then spends + * hundreds of FA2 iterations refining, this engine promotes it to the *solver*: the + * {@link SparseStressSolver} runs sparse-stress SGD (Zheng et al.) over the Ortmann + * sparse-stress model AND fuses an eta-scaled node-size proximity/overlap term into + * the same SGD step (PRISM-style overlap-as-stress; Gansner & Hu 2010), so distance + * fidelity and non-overlap converge jointly instead of fighting a terminal overlap + * pass. The epoch count is adaptive — the solver stops once the layout stops moving + * rather than at a fixed horizon. + * + * Pipeline: + * 1. Louvain over the link graph → community id per node (for BubbleSets hulls), + * exactly as the FA2 engine does, so the community layer is unchanged. + * 2. Sparse-stress SGD with the fused overlap term, to adaptive convergence (pivot + * terms carry the global structure, edge terms the local structure, the overlap + * term keeps same-community dots from piling up around hubs). + * + * Streaming `absorb` continues from the current positions (SGD is init-robust), so + * new nodes settle in beside their neighbours without a cold restart. + */ +import { UndirectedGraph } from "graphology"; +import louvain from "graphology-communities-louvain"; + +import { parkMillerRng } from "../../math/random"; +import { SparseStressSolver } from "./sparse-stress-solver"; + +import type { FlatGraphBuffer } from "../buffers/position-buffer"; +import type { + ForceEdge, + ForceLayoutStatus, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Layout-space length for one graph hop (matches the FA2 engine's seed scale). */ +const IDEAL_LINK_LENGTH = 60; +/** + * Extra gap enforced between node disks by the fused overlap term (world units, on top + * of `r_i + r_j`). Larger than the FA2 engine's `adjustSizes` padding because the stress + * solver lays out compactly (excellent edge fidelity, small spread), so it needs more + * explicit breathing room to keep hub neighbourhoods from crowding (see the + * weight/padding sweep in stress-vs-fa2.bench.ts). + */ +const OVERLAP_PADDING = 8; +/** Deterministic init jitter (fraction of the ideal edge length) so coincident nodes separate. */ +const SEED_JITTER = 0.01; +/** Solver work units per advance; the layout's ms budget bounds how many run per tick. */ +const SEED_TICK_WORK = 16384; + +/** + * Adaptive epoch bounds for a cold solve. The eta schedule anneals over MAX_EPOCHS, + * but the solver stops early once movement settles (never before MIN_EPOCHS, so the + * high-eta opening epochs don't trip the stop). A sparse-stress epoch is + * O(edges + nodes·pivots + nodes) — far below one FA2 iteration's O(N log N) — so a + * generous horizon is cheap. + */ +const MAX_EPOCHS = 60; +const MIN_EPOCHS = 8; +/** Warm-continue horizon after an absorb — smaller, since we resume from settled positions. */ +const ABSORB_MAX_EPOCHS = 16; +/** Adaptive-stop tolerance: normalized max per-epoch node movement (see SparseStressSolver). */ +const CONVERGENCE_EPSILON = 3e-3; +/** + * Relaxation weight for the fused overlap term, relative to the edge weight (1). >1 makes + * non-overlap win more of the tug-of-war against edge tension: it trades a little edge-length + * fidelity (of which the stress solver has plenty to spare vs FA2) for materially fewer + * overlaps. 4 keeps edge stress below FA2 at every tested size while cutting overlaps at + * the 1.5k/5k scales (see stress-vs-fa2.bench.ts). + */ +const OVERLAP_WEIGHT = 4; + +/** Refresh Louvain once the layout grows by this fraction, so BubbleSets track it (matches FA2). */ +const LOUVAIN_REFRESH_GROWTH_FRACTION = 0.3; +const LOUVAIN_REFRESH_MIN_NEW_NODES = 24; + +/** A resolved index pair plus accumulated weight (parallel links merged). */ +interface IndexEdge { + readonly source: number; + readonly target: number; + readonly weight: number; +} + +type StressPhase = "stress" | "done"; + +export interface StressLayoutOptions { + readonly idealEdgeLength?: number; + /** Adaptive-epoch horizon / hard cap for a cold solve. Default 60. */ + readonly maxEpochs?: number; + /** Minimum epochs before the adaptive stop can fire. Default 8. */ + readonly minEpochs?: number; + /** Adaptive-epoch horizon for warm absorbs. Default 16. */ + readonly absorbMaxEpochs?: number; + /** Adaptive-stop tolerance (normalized per-epoch movement). Default 3e-3. */ + readonly convergenceEpsilon?: number; + /** Extra gap between node disks enforced by the fused overlap term. Default 8. */ + readonly overlapPadding?: number; + /** Relaxation weight for the fused overlap term, relative to edge weight. Default 4. */ + readonly overlapWeight?: number; +} + +class StressLayout implements LayoutSimulation { + readonly #nodes: ForceNode[]; + readonly #buffer: FlatGraphBuffer; + readonly #idToIndex = new Map(); + readonly #communities: number[]; + readonly #options: Required; + + #indexEdges: IndexEdge[] = []; + /** Solver coordinates: the solver mutates these in place. Source of truth. */ + #x: Float32Array; + #y: Float32Array; + /** Per-node collision radius (drawn radius); the overlap padding is added by the solver. */ + #radii: Float32Array; + + #solver: SparseStressSolver | null; + #status: ForceLayoutStatus; + #phase: StressPhase; + + #absorbedSinceLouvain = 0; + #countAtLastLouvain = 0; + + constructor( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + options: StressLayoutOptions = {}, + ) { + this.#nodes = nodes; + this.#buffer = buffer; + this.#options = { + idealEdgeLength: options.idealEdgeLength ?? IDEAL_LINK_LENGTH, + maxEpochs: options.maxEpochs ?? MAX_EPOCHS, + minEpochs: options.minEpochs ?? MIN_EPOCHS, + absorbMaxEpochs: options.absorbMaxEpochs ?? ABSORB_MAX_EPOCHS, + convergenceEpsilon: options.convergenceEpsilon ?? CONVERGENCE_EPSILON, + overlapPadding: options.overlapPadding ?? OVERLAP_PADDING, + overlapWeight: options.overlapWeight ?? OVERLAP_WEIGHT, + }; + + const count = nodes.length; + for (const [index, node] of nodes.entries()) { + this.#idToIndex.set(node.id, index); + } + this.#communities = Array.from({ length: count }).fill(-1); + this.#countAtLastLouvain = count; + + this.#x = new Float32Array(count); + this.#y = new Float32Array(count); + this.#radii = this.#buildRadii(); + + this.#indexEdges = StressLayout.resolveEdges(edges, this.#idToIndex); + this.#runLouvain(); + + this.#solver = + count > 0 + ? this.#buildSolver( + this.#options.maxEpochs, + this.#options.minEpochs, + false, + ) + : null; + this.#status = count > 0 ? "running" : "settled"; + this.#phase = count > 0 ? "stress" : "done"; + this.#writePositions(); + } + + get status(): ForceLayoutStatus { + return this.#status; + } + + get isSettled(): boolean { + return this.#status === "settled"; + } + + get nodes(): readonly ForceNode[] { + return this.#nodes; + } + + get buffer(): SharedArrayBuffer | ArrayBuffer { + return this.#buffer.raw; + } + + get nodeIds(): string[] { + return this.#nodes.map((node) => node.id); + } + + get alpha(): number { + return this.#phase === "done" ? 0 : 1; + } + + /** Louvain community id per node, in buffer order (for BubbleSets / seeding). */ + get communities(): readonly number[] { + return this.#communities; + } + + tick(budgetMs: number): boolean { + if (this.#status === "settled" || this.#status === "paused") { + return false; + } + this.#status = "running"; + const startTime = performance.now(); + let stepped = false; + + while (performance.now() - startTime < budgetMs && this.#phase !== "done") { + this.#advance(); + stepped = true; + } + + if (stepped) { + this.#writePositions(); + } + if (this.#phase === "done") { + this.#status = "settled"; + } + return stepped; + } + + pause(): void { + if (this.#status === "running") { + this.#status = "paused"; + } + } + + resume(): void { + if (this.#status === "paused") { + this.#status = "running"; + } + } + + /** + * Absorb newly-arrived nodes without a cold restart: append them (existing indices + * keep their slot so the shared buffer grows in place), rebuild the edge topology, + * and continue SGD from the preserved positions (SGD is indifferent to + * initialization, so warm-continue is well-defined). Refreshes Louvain once the + * layout has grown enough, so the BubbleSets track the evolving communities. + */ + absorb(newNodes: ForceNode[], edges: ForceEdge[]): void { + const previousCount = this.#nodes.length; + for (const node of newNodes) { + this.#idToIndex.set(node.id, this.#nodes.length); + this.#nodes.push(node); + this.#communities.push(-1); + } + const count = this.#nodes.length; + + // Grow the coordinate arrays, preserving settled positions and taking each new + // node's incoming (neighbour-seeded) coordinate from its ForceNode. + const nextX = new Float32Array(count); + const nextY = new Float32Array(count); + nextX.set(this.#x.subarray(0, previousCount)); + nextY.set(this.#y.subarray(0, previousCount)); + for (let index = previousCount; index < count; index++) { + const node = this.#nodes[index]!; + nextX[index] = node.x ?? 0; + nextY[index] = node.y ?? 0; + } + this.#x = nextX; + this.#y = nextY; + this.#radii = this.#buildRadii(); + + this.#indexEdges = StressLayout.resolveEdges(edges, this.#idToIndex); + this.#absorbedSinceLouvain += newNodes.length; + + const refreshAt = Math.max( + LOUVAIN_REFRESH_MIN_NEW_NODES, + Math.ceil(this.#countAtLastLouvain * LOUVAIN_REFRESH_GROWTH_FRACTION), + ); + if (count > 0 && this.#absorbedSinceLouvain >= refreshAt) { + this.#runLouvain(); + this.#absorbedSinceLouvain = 0; + this.#countAtLastLouvain = count; + } + + // Warm-continue SGD from the current positions (keepInitialPositions), not a + // fresh PivotMDS init: existing structure is preserved, new nodes relax in. + this.#solver = + count > 0 + ? this.#buildSolver( + this.#options.absorbMaxEpochs, + Math.min(2, this.#options.absorbMaxEpochs), + true, + ) + : null; + this.#phase = count > 0 ? "stress" : "done"; + this.#status = count > 0 ? "running" : "settled"; + this.#writePositions(); + } + + /** + * Force a Louvain refresh if nodes were absorbed since the last one (trailing-edge + * complement to the growth trigger). Position-neutral; returns whether it ran. + */ + refreshCommunities(): boolean { + if (this.#absorbedSinceLouvain === 0) { + return false; + } + this.#runLouvain(); + this.#absorbedSinceLouvain = 0; + this.#countAtLastLouvain = this.#nodes.length; + return true; + } + + /** One unit of work: advance the solver. Overlap resolution is fused into its SGD. */ + #advance(): void { + if (this.#phase !== "stress") { + return; + } + const result = this.#solver!.tick({ maxWork: SEED_TICK_WORK }); + if (result.done) { + this.#phase = "done"; + } + } + + #buildRadii(): Float32Array { + const count = this.#nodes.length; + const radii = new Float32Array(count); + for (let index = 0; index < count; index++) { + radii[index] = this.#nodes[index]!.radius; + } + return radii; + } + + /** + * Build a {@link SparseStressSolver} over the current link graph, writing into the + * shared `#x`/`#y` arrays and using `#radii` for the fused overlap term. `warm` + * continues from the current positions (absorb); otherwise the solver computes a + * fresh PivotMDS initialization (cold build). The epoch count is adaptive within + * `[minEpochs, maxEpochs]`. + */ + #buildSolver( + maxEpochs: number, + minEpochs: number, + warm: boolean, + ): SparseStressSolver { + const count = this.#nodes.length; + const edgeCount = this.#indexEdges.length; + const src = new Uint32Array(edgeCount); + const dst = new Uint32Array(edgeCount); + for (let index = 0; index < edgeCount; index++) { + const edge = this.#indexEdges[index]!; + src[index] = edge.source; + dst[index] = edge.target; + } + + return new SparseStressSolver( + { n: count, src, dst, x: this.#x, y: this.#y, radii: this.#radii }, + { + idealEdgeLength: this.#options.idealEdgeLength, + randomSeed: 1, + jitter: SEED_JITTER, + maxEpochs, + minEpochs, + convergenceEpsilon: this.#options.convergenceEpsilon, + overlapPadding: this.#options.overlapPadding, + overlapWeight: this.#options.overlapWeight, + keepInitialPositions: warm, + packComponents: true, + returnPivotDistances: false, + }, + ); + } + + /** Run seeded Louvain over the link graph; fill `#communities` (singletons if no edges). */ + #runLouvain(): void { + if (this.#indexEdges.length === 0) { + for (let index = 0; index < this.#nodes.length; index++) { + this.#communities[index] = index; + } + return; + } + + const graph = new UndirectedGraph< + Record, + { weight: number } + >(); + for (const node of this.#nodes) { + graph.addNode(node.id); + } + for (const edge of this.#indexEdges) { + graph.mergeEdge( + this.#nodes[edge.source]!.id, + this.#nodes[edge.target]!.id, + { weight: edge.weight }, + ); + } + + const membership = louvain(graph, { + getEdgeWeight: "weight", + randomWalk: false, + rng: parkMillerRng(1), + }); + for (let index = 0; index < this.#nodes.length; index++) { + this.#communities[index] = membership[this.#nodes[index]!.id] ?? index; + } + } + + /** + * Re-centre the solver coordinates on their centroid and publish them to the shared + * buffer + the mirrored ForceNode view (so a warm absorb can read settled coords). + * `#x`/`#y` themselves are left un-centred (the solver's own frame). + */ + #writePositions(): void { + const count = this.#nodes.length; + + let centroidX = 0; + let centroidY = 0; + for (let index = 0; index < count; index++) { + centroidX += this.#x[index]!; + centroidY += this.#y[index]!; + } + centroidX = count > 0 ? centroidX / count : 0; + centroidY = count > 0 ? centroidY / count : 0; + + for (let index = 0; index < count; index++) { + const localX = this.#x[index]! - centroidX; + const localY = this.#y[index]! - centroidY; + this.#nodes[index]!.x = localX; + this.#nodes[index]!.y = localY; + this.#buffer.setPosition(index, localX, localY); + } + this.#buffer.commit(); + } + + /** Resolve string/object endpoints to index pairs, drop self/dangling, merge parallels. */ + private static resolveEdges( + edges: ForceEdge[], + idToIndex: Map, + ): IndexEdge[] { + const weightByPair = new Map(); + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + const lo = Math.min(source, target); + const hi = Math.max(source, target); + const key = `${lo}:${hi}`; + const existing = weightByPair.get(key); + weightByPair.set(key, { + source: lo, + target: hi, + weight: (existing?.weight ?? 0) + edge.weight, + }); + } + return [...weightByPair.values()]; + } +} + +export function createStressLayout( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + options?: StressLayoutOptions, +): LayoutSimulation { + return new StressLayout(nodes, edges, buffer, options); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts new file mode 100644 index 00000000000..f6e77043d1c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts @@ -0,0 +1,273 @@ +/* eslint-disable no-console -- committed A/B harness whose whole purpose is to PRINT a + side-by-side FA2-vs-stress comparison table (time + layout quality). */ +/** + * Head-to-head comparison of the two community-tier layout engines on identical + * deterministic fixtures: + * + * - FA2 : createCommunityLayout (Louvain → sparse-stress SEED → FA2 refine) + * - stress : createStressLayout (Louvain → sparse-stress SOLVER w/ fused overlap term) + * + * Two outputs, both seeded so runs are reproducible: + * + * 1. A module-scope quality + wall-time table (printed on import): for each size it + * drives BOTH engines to `settled` with the production 1 ms tick budget, takes the + * least-noisy wall time, and computes layout-quality proxies from the settled + * positions — normalized edge stress (how close edge lengths sit to the ideal), + * edge-length CV (uniformity), strict node overlaps, and spread (RMS radius). + * + * 2. vitest `bench()` timing blocks (statistical) for the two smaller sizes, as a + * cross-check on the wall times in the table. + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildForceGraph } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createCommunityLayout } from "./community-layout"; +import { countOverlaps } from "./overlap-relax"; +import { createStressLayout } from "./stress-layout"; + +import type { GraphShape } from "../bench-fixtures"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Ideal layout-space hop length; both engines target this scale (SEED_IDEAL_LINK_LENGTH). */ +const IDEAL = 40; + +function shape(nodeCount: number, linkCount: number, seed: number): GraphShape { + return { + nodeCount, + linkCount, + typeCount: 1, + hubCount: Math.max(4, Math.round(nodeCount / 40)), + rootFraction: 1, + seed, + }; +} + +/** ~300 / ~1.5k / ~5k nodes at ~2.6 hub-skewed edges/node (matches the cost harness). */ +const AB_CASES: readonly GraphShape[] = [ + shape(300, 780, 301), + shape(1_500, 3_900, 302), + shape(5_000, 13_000, 303), +]; + +type LayoutFactory = ( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, +) => LayoutSimulation; + +const ENGINES: readonly { + readonly name: string; + readonly make: LayoutFactory; +}[] = [ + { + name: "FA2", + make: (nodes, edges, buffer) => createCommunityLayout(nodes, edges, buffer), + }, + { + name: "stress", + make: (nodes, edges, buffer) => createStressLayout(nodes, edges, buffer), + }, +]; + +/** Fresh node objects per run: the solvers mutate x/y in place. */ +function cloneNodes(nodes: readonly ForceNode[]): ForceNode[] { + return nodes.map((node) => ({ ...node })); +} + +/** Drive to convergence with the worker scheduler's 1 ms budget (production cadence). */ +function driveToSettled(layout: LayoutSimulation): void { + let guard = 0; + while (!layout.isSettled && guard < 10_000_000) { + if (!layout.tick(1)) { + break; + } + guard += 1; + } +} + +interface Quality { + readonly edgeStress: number; + readonly edgeCv: number; + readonly overlaps: number; + readonly spread: number; +} + +/** Layout-quality proxies from the settled node positions. */ +function measureQuality( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], +): Quality { + const count = nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + + let sumStress = 0; + let sumLen = 0; + let sumLenSq = 0; + let edgeCount = 0; + const seen = new Set(); + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + const lo = Math.min(source, target); + const hi = Math.max(source, target); + const key = `${lo}:${hi}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + const length = Math.hypot(x[hi]! - x[lo]!, y[hi]! - y[lo]!); + const ratio = length / IDEAL - 1; + sumStress += ratio * ratio; + sumLen += length; + sumLenSq += length * length; + edgeCount += 1; + } + + const meanLen = edgeCount > 0 ? sumLen / edgeCount : 0; + const variance = edgeCount > 0 ? sumLenSq / edgeCount - meanLen * meanLen : 0; + const edgeCv = meanLen > 0 ? Math.sqrt(Math.max(0, variance)) / meanLen : 0; + + let sumRadiusSq = 0; + for (let index = 0; index < count; index++) { + sumRadiusSq += x[index]! * x[index]! + y[index]! * y[index]!; + } + + return { + edgeStress: edgeCount > 0 ? Math.sqrt(sumStress / edgeCount) : 0, + edgeCv, + overlaps: countOverlaps({ x, y, radii, count, padding: 0 }), + spread: count > 0 ? Math.sqrt(sumRadiusSq / count) : 0, + }; +} + +/** Least-noisy (min) construct-to-settled wall time over `runs`, plus a kept settled layout. */ +function solveMin( + make: LayoutFactory, + nodes: readonly ForceNode[], + edges: ForceEdge[], + runs: number, +): { readonly minMs: number; readonly layout: LayoutSimulation } { + let minMs = Number.POSITIVE_INFINITY; + let kept: LayoutSimulation | undefined; + for (let run = 0; run < runs; run++) { + const runNodes = cloneNodes(nodes); + const buffer = new FlatGraphBuffer(Math.max(1, runNodes.length)); + const start = performance.now(); + const layout = make(runNodes, edges, buffer); + driveToSettled(layout); + const elapsed = performance.now() - start; + if (elapsed < minMs) { + minMs = elapsed; + kept = layout; + } + } + return { minMs, layout: kept! }; +} + +function pad(text: string, width: number, alignRight = true): string { + return alignRight ? text.padStart(width) : text.padEnd(width); +} + +function comparisonReport(caseShape: GraphShape): string { + const { nodes, edges } = buildForceGraph(caseShape); + const columns = [8, 12, 12, 10, 10, 10]; + const row = (cells: readonly string[]): string => + [ + pad(cells[0]!, columns[0]!, false), + pad(cells[1]!, columns[1]!), + pad(cells[2]!, columns[2]!), + pad(cells[3]!, columns[3]!), + pad(cells[4]!, columns[4]!), + pad(cells[5]!, columns[5]!), + ].join(" "); + + const lines: string[] = []; + lines.push( + `\n=== FA2 vs stress: ${nodes.length} nodes / ${edges.length} edges ` + + `(barnesHut ${caseShape.nodeCount > 2000 ? "ON" : "OFF"}) ===`, + ); + const header = row([ + "engine", + "wall ms", + "edgeStress", + "edgeCV", + "overlaps", + "spread", + ]); + lines.push(header); + lines.push("-".repeat(header.length)); + + for (const engine of ENGINES) { + // FA2 is far slower, so fewer timed runs; both take the least-noisy minimum. + const runs = engine.name === "FA2" ? 2 : 3; + const { minMs, layout } = solveMin(engine.make, nodes, edges, runs); + const quality = measureQuality(layout.nodes, edges); + lines.push( + row([ + engine.name, + minMs.toFixed(1), + quality.edgeStress.toFixed(4), + quality.edgeCv.toFixed(4), + String(quality.overlaps), + quality.spread.toFixed(1), + ]), + ); + } + + return lines.join("\n"); +} + +for (const caseShape of AB_CASES) { + console.log(comparisonReport(caseShape)); +} + +/** Statistical timing cross-check for the two smaller sizes (5k is timed in the table above). */ +const BENCH_OPTIONS = { + time: 0, + iterations: 3, + warmupTime: 0, + warmupIterations: 1, +} as const; + +for (const caseShape of AB_CASES.slice(0, 2)) { + describe(`community layout solve (${caseShape.nodeCount} nodes)`, () => { + const { nodes, edges } = buildForceGraph(caseShape); + for (const engine of ENGINES) { + bench( + engine.name, + () => { + const runNodes = cloneNodes(nodes); + const buffer = new FlatGraphBuffer(Math.max(1, runNodes.length)); + driveToSettled(engine.make(runNodes, edges, buffer)); + }, + BENCH_OPTIONS, + ); + } + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts index 0390b1e2f87..1443d8e0b0c 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/top-level-layout.ts @@ -34,7 +34,7 @@ * are still placed freely, and the layout keeps self-healing crossings — it just * does so by small, legible adjustments instead of wholesale reshuffles. */ -import { mulberry32 } from "../random"; +import { mulberry32 } from "../../math/random"; /** A node being laid out; `x`/`y` are mutated in place. */ export interface LayoutNode { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts index fbf98858627..46b6bb1052e 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/untangle.ts @@ -32,7 +32,7 @@ * `totalEnergy` (O(E² + E*N + N²)) is computed only once per restart, to pick * the winner. */ -import { mulberry32 } from "../random"; +import { mulberry32 } from "../../math/random"; /** Mutated in place. Positions are in the layout's local frame (origin-centred). */ export interface UntangleNode { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts index 3aa14342165..1ae430d1ee5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/link.ts @@ -121,35 +121,38 @@ export class LinkStore { return this.#entity.get(linkId); } + degreeOf(entityIndex: EntityIndex): number { + return this.#adjacency.get(entityIndex)?.length ?? 0; + } + /** All links touching an entity, with direction. O(degree). */ - linksFor(entityIndex: EntityIndex): LinkEndpoint[] { + *linksFor( + entityIndex: EntityIndex, + ): Generator { const linkIds = this.#adjacency.get(entityIndex); if (!linkIds) { - return []; + return; } - const result: LinkEndpoint[] = []; for (const linkId of linkIds) { const left = this.#left.get(linkId); const right = this.#right.get(linkId); if (left === entityIndex && right !== -1) { - result.push({ + yield { linkId, otherId: right, typeSetId: this.#type.get(linkId), direction: "out", - }); + }; } else if (right === entityIndex && left !== -1) { - result.push({ + yield { linkId, otherId: left, typeSetId: this.#type.get(linkId), direction: "in", - }); + }; } } - - return result; } } From 70b32b7e287c570f9ae40bc45fc958cad257d901 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:38:05 +0200 Subject: [PATCH 44/78] feat: code review --- .../shared/graph-visualizer-2/PERFORMANCE.md | 708 +++++++++++++ .../worker/core/graph-worker.ts | 7 +- .../worker/layout/overlap-removal.ts | 983 ++++++++++++++++++ 3 files changed, 1697 insertions(+), 1 deletion(-) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md new file mode 100644 index 00000000000..e3dcd68fc53 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md @@ -0,0 +1,708 @@ +# Graph‑visualizer‑2 renderer — WebGL performance audit + +This report covers the **main‑thread rendering pipeline** (`render/**`) of the v2 +graph visualizer and its **coordination with the layout worker**. It is the +companion to [`worker/PERFORMANCE.md`](./worker/PERFORMANCE.md), which covers the +worker‑internal cost (ingest, community detection, layout settle, and the +per‑tick geometry emission in `#emitPositions`). Where the two overlap I point at +the worker doc rather than re‑derive it. + +Rendering is [deck.gl](https://deck.gl) **9.3.4** (luma.gl 9.3.5) driven +imperatively from a single `Scene` (`render/scene.ts`) that subscribes to the +worker handle's coalesced event stream. There is no React state on the render +path; the React shell (`graph-visualizer.tsx`) only mounts/disposes the `Scene`. + +Everything here is grounded in the code. Claims I could not fully settle from +static reading (they depend on graph size, tier, and GPU) are marked **VERIFY** +with a concrete way to measure. + +--- + +## 0. What is already good (so we don't "fix" it) + +Two things that a generic WebGL audit would flag are **already handled**, and the +findings below are careful not to contradict them: + +- **The render loop is on‑demand, not a free‑running `rAF`.** deck.gl only issues + GPU draw calls when `needsRedraw` is set (a `setProps`, a viewState change, or a + transition). The `Scene` never calls `deck.redraw()` on a timer and never sets + `_animate`. When nothing changes, no GPU frames are drawn. +- **The worker stops streaming when the layout converges.** The tick scheduler + (`worker/core/graph-worker.ts` `#tickAllLayouts` → `#scheduleNextTick`) shuts + itself off once `!#anyLayoutRunning()`, and every layout engine has a terminal + `settled`/`done` state (`force-simulation.ts` `SETTLE_ALPHA`, `flat-layout.ts` + and `stress-layout.ts` phase `"done"`). So a static graph does **not** emit + position frames forever, and the renderer goes idle with it. + +The consequence: the biggest wins are **not** "stop rendering when idle" (that +already happens). They are about the **cost of each frame while the layout is +settling or streaming**, and about **work the renderer redoes that only needed to +happen once per topology change**. + +--- + +## 1. Summary & priority + +| # | Problem | Theme | Tier(s) affected | Impact | +| --- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | --------------------------- | ---------- | +| R1 | Whole layer set is rebuilt from scratch on **every** position frame | Render loop | all | **High** | +| R2 | Each `StructureFrame` re‑resolves icons/labels for **every visible dot**, and structure frames fire once per ingest batch | Worker↔main coordination | flat‑force, community‑force | **High** | +| R3 | Community BubbleSets re‑group O(nodes) on the CPU and **destroy+recreate the GPU texture every frame** | GPU buffers/textures | community‑force | **High** | +| R4 | Hierarchical leaf edges are gathered on the main thread and re‑uploaded every frame | GPU buffers | hierarchical‑lod | **Medium** | +| R5 | Bézier SDF edges are drawn **twice** (underlay + main), each an expensive per‑pixel curve solve, over overlapping padded quads | Shaders / overdraw | hierarchical‑lod | **Medium** | +| R6 | Label layers are rebuilt and **all layers re‑pushed on every zoom delta** | Render loop | all | **Medium** | +| R7 | Hub‑label projection sets React state at frame rate (re‑renders the whole bridge) | Render loop / React | flat‑force, community‑force | **Medium** | +| R8 | `PositionsFrame.settled` is computed but never read, and it ignores flat/entity layouts | Coordination (opportunity) | all | **Medium** | +| R9 | BubbleSet metaball shader loops up to 256 nodes **per pixel** over overlapping quads | Shaders / overdraw | community‑force | **Medium** | +| R10 | Bubble‑hover triggers a second full picking render (`#edgePickFor`) | Picking | hierarchical‑lod | **Low** | +| R11 | `edgeArrowLayer` split cache misses every frame; `TextLayer` `characterSet:"auto"` | Misc | all | **Low** | + +The single highest‑leverage change is **R1+R2 together**: make the position‑frame +handler mutate/patch the existing layers instead of rebuilding them, and make the +structure‑frame handler resolve icons per _type_ (not per _dot_) and only for +added dots. Those two dominate main‑thread frame cost while a graph is streaming +in or settling. + +--- + +## 2. Render loop + +### R1 — The entire layer set is rebuilt on every position frame — **High** + +**Where.** `render/scene.ts`, the position branch of `#handleEvent`: + +```581:596:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts + this.#positionTick += 1; + updatePlaced(this.#placed, positions); + this.#dataLayers = this.#buildDataLayers(structure, positions); + this.#rebuildLabels(); + this.#overlayLayers = this.#buildOverlay(); + this.#pushLayers(); + // The selected node may have moved this tick: refresh its tracked screen position. + this.#emitSelection(); + this.#emitHighwayHover(); + this.#emitClusterHover(); + this.#scheduleEntityLabels(); +``` + +This runs once per coalesced position frame — i.e. ~once per animation frame for +the whole duration a layout is settling (and once per ingest batch while +streaming). `#buildDataLayers` (`scene.ts:601‑680`) constructs a **fresh set of +`Layer` instances every time**: `edgeLayer(...)`, `clusterBubbleLayer(...)`, +`clusterEntityLayers(...)`, `flatDotsLayer(...)`, `communityLayer(...)`, +`typeIconLayer(...)`, plus overlay + label layers, then `#pushLayers` calls +`deck.setProps({ layers })`. + +**Why it hurts.** + +- deck.gl reconciles by matching layer `id`s and diffing props. Handing it a new + array of new instances each frame forces a full **props diff + `updateState` + pass across every layer, every frame**, on top of the attribute re‑uploads that + are genuinely needed. Most layers use `updateTriggers: { getPosition: +positionTick }`, and `positionTick` increments every frame, so the position + attribute is re‑uploaded for **all** layers each frame — including ones whose + positions did not actually move (e.g. bubbles that settled several frames ago, + or the entire graph once the macro layout is done but a single leaf is still + relaxing). +- The CPU allocation per frame is substantial: `clusterEntityLayers` and the flat + builders allocate typed arrays and layer objects that are thrown away next + frame, feeding GC pressure during the exact window (settling) when the main + thread is busiest. + +**Impact.** This is the dominant main‑thread render cost while settling/streaming. +It scales with the number of open leaves and edges (see R4) and with layer count, +not just with what changed. + +**Fix (in order of leverage).** + +1. **Persist layers; drive updates through `updateTriggers` only.** The bubble + layer already demonstrates the pattern — `#placed` is a persistent array whose + contents are mutated in place (`clusters.ts` `updatePlaced`) and only the + `getPosition` trigger bumps. Extend this to the other data layers: keep the + `Layer` instances in fields, and on a position frame update their binary + attribute `value`s / bump their triggers instead of `new`‑ing them. deck will + then re‑upload only what changed. +2. **Gate per‑layer position re‑upload on "did this layer actually move".** The + worker knows which layouts ticked (`#tickAllLayouts` sets `clusterMoved` / + `flatMoved` per layout); if that per‑layout "moved" bit rode the frame, the + `Scene` could bump `positionTick` only for the layers whose layout moved, and + leave settled leaves' attributes untouched. Today a single unsettled leaf + re‑uploads every leaf's dots. +3. At minimum, **skip label + overlay rebuilds on position frames when nothing + relevant changed** (see R6 for the zoom path; the same applies here). + +--- + +### R6 — Label layers rebuilt and all layers re‑pushed on every zoom delta — **Medium** + +**Where.** `render/scene.ts` `#applyViewState` runs on every `onViewStateChange` +(every wheel tick / drag frame from deck's controller): + +```717:736:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts + if (zoomChanged) { + this.#rebuildLabels(); + } + if (labelEligibilityChanged) { + this.#rebuildEntityLabelData(); + } + if (iconEligibilityChanged) { + this.#refreshDataLayers(); + } else if (zoomChanged) { + this.#pushLayers(); + } +``` + +`#rebuildLabels` (`scene.ts:1448`) builds **new** `TextLayer` instances for +cluster + edge labels, and `clusterLabelLayer` (`render/labels.ts:63`) rebuilds +its `data` array over all clusters and uses `updateTriggers: { getColor: zoom }` +with the **continuous** zoom value: + +```105:108:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts + updateTriggers: { + getColor: zoom, + }, +``` + +**Why it hurts.** During a continuous trackpad zoom (~60–120 events/s) every event +rebuilds the label `TextLayer`s and re‑pushes the entire layer array. A +continuous `zoom` trigger means the `getColor` accessor re‑runs over all labels on +every sub‑pixel zoom delta (deck only skips work when the trigger value is +_equal_). `TextLayer` with `characterSet: "auto"` re‑derives its glyph set when +`data` identity changes, which it does every rebuild. A pan (`zoomChanged` false) +correctly avoids the rebuild — but it still calls `#pushLayers`‑adjacent emit work +(fine). The concern is zoom. + +**Impact.** Extra main‑thread work bounded by (label count × zoom events). Small +for a handful of cluster labels; noticeable when there are many cluster/edge +labels and the user scrubs zoom. + +**Fix.** + +- Quantize the label‑color trigger to the existing zoom _bucket_ + (`labelZoomBucket`, already computed at `scene.ts:703`) instead of the raw + `zoom`, so `getColor` re‑runs only when the fade actually steps. The label fade + is smooth over `LABEL_FADE_PX`, so a bucketed trigger is visually equivalent. +- Keep persistent label `TextLayer`s (as in R1) and update triggers instead of + re‑`new`‑ing, so `characterSet:"auto"` doesn't re‑scan each zoom event. Or set + an explicit `characterSet` from the known label text. +- Consider `@deck.gl/extensions` `CollisionFilterExtension` for label decluttering + (the code already has a `PERF TODO` for this at `labels.ts:6`). + +--- + +### R7 — Hub‑label projection sets React state at frame rate — **Medium** + +**Where.** On every position frame and every view change the `Scene` schedules +`#emitEntityLabels`, which projects the cached hub set to screen and calls +`onEntityLabels`: + +```1485:1493:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts + #scheduleEntityLabels(): void { + if (this.#entityLabelsFrame !== null) { + return; + } + this.#entityLabelsFrame = requestAnimationFrame(() => { + this.#entityLabelsFrame = null; + this.#emitEntityLabels(); + }); + } +``` + +`onEntityLabels` is wired straight to a React `setState` +(`entity-graph-visualizer.tsx:739`, `onEntityLabels={setEntityLabels}`). + +**Why it hurts.** `setEntityLabels` runs every animation frame while a graph +settles (and while panning/zooming when hubs are visible), re‑rendering the +memoized `EntityGraphVisualizerV2` and its subtree (`EntityLabelOverlay`, cards, +controls). It's rAF‑coalesced and the hub set is capped at `HUB_LABEL_MAX_COUNT = +12` (`scene.ts:94`), so it's bounded — but it is a React reconciliation per frame +during the busiest window, and it churns even when no hub actually moved on screen +(a settled graph that is still emitting frames for one relaxing leaf). + +**Impact.** Medium on the flat tiers when zoomed in enough for hub labels to show; +zero in hierarchical‑lod (that tier emits no always‑on entity labels). + +**Fix.** Diff before emitting: skip `onEntityLabels` when the projected label set +is unchanged from the last emit (same ids + same rounded x/y). Since positions are +frozen once settled, this collapses to zero React updates when idle. Optionally +render the overlay imperatively (like the cards' transform updates) rather than +through React state. + +--- + +## 3. Worker ↔ main‑thread coordination + +### R2 — Each structure frame re‑resolves icons/labels for every visible dot — **High** + +**Where.** A `structure` event triggers two O(visible‑dots) scans on the main +thread: + +```573:577:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts + this.#rebuildEntityLabelData(); + + // Same gating for the per-dot type-icon keys (the only O(dots) icon-resolution scan). + this.#rebuildEntityIconData(); +``` + +`#rebuildEntityIconData` (`scene.ts:1636`) scans **every** record of every visible +layout and calls the React `resolveEntityIcon` resolver **per dot**: + +```1648:1665:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts + const scanLayout = ( + layoutId: ClusterId, + count: number, + ): (string | null)[] => { + const names = Array.from({ length: count }).fill(null); + for (let index = 0; index < count; index++) { + const entityId = this.#handle.resolveEntityId(layoutId, index); + if (entityId === undefined) { + continue; + } + const key = resolveIcon(entityId); + if (key !== null && key.length > 0) { + names[index] = key; + keys.add(key); + } + } + return names; + }; +``` + +`resolveIcon` is `resolveEntityIcon` from the bridge, which per call does +`getClosedMultiEntityTypeFromMap(...)` + `getDisplayFieldsForClosedEntityType(...)` +(`entity-graph-visualizer.tsx:411‑429`) — a type‑hierarchy walk, **per dot**. + +Crucially, structure frames are **not** rare while streaming: `entry.ts` commits +on every ingest batch, and `#commitFlat` always emits a structure frame: + +```101:103:apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts + const deltas = worker.ingestBatch(data.entities); + const tIngest = performance.now(); + worker.commitStructure({ deltas }); +``` + +The bridge streams `entities` as a growing tail (`entity-graph-visualizer.tsx:445‑460`), +so a large result set that arrives in K batches produces ~K structure frames, each +triggering a full O(dots) icon re‑resolution over the whole current graph. + +**Why it hurts.** Icon identity is a function of an entity's **type**, not the +entity — but the scan resolves it once per **dot**. For N dots arriving over K +batches this is ≈ O(K·N) type‑hierarchy walks on the main thread, interleaved with +the R1 layer rebuilds. This is the render‑side twin of worker findings **F1/F2** +(no no‑op/incremental commit; streaming re‑does whole‑graph work per batch). + +**Impact.** High during initial load / frontier expansion of medium‑to‑large +graphs. It's the main reason ingest can feel janky beyond the worker cost already +documented. + +**Fix.** + +1. **Resolve icons per type, not per dot.** Memoize `resolveEntityIcon` by the + entity's `entityTypeIds` key (icons are per closed type). Thousands of per‑dot + calls collapse to one per distinct type‑set. This is a pure bridge‑side change. +2. **Only scan added dots.** The flat SAB appends new records at the tail + (`FlatGraphBuffer` over‑allocates; existing records keep their slot). Track the + last‑scanned count and resolve icons/labels only for `[prevCount, count)` on a + structure frame that only grew, reusing the cached prefix. Full rescans then + happen only on a reorder/mode change. +3. **Coalesce structure frames during a streaming burst** — this is worker fix #2 + in `worker/PERFORMANCE.md`; every structure frame elided there is an O(dots) + rescan elided here. + +--- + +### R8 — `PositionsFrame.settled` is dead, and it ignores flat/entity layouts — **Medium (opportunity)** + +**Where.** The worker sets a `settled` flag on every positions frame: + +```1753:1762:apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts + this.#positionVersion++; + this.#onPositionsFrame?.({ + version: this.#positionVersion, + settled: !this.#anyClusterLayoutRunning(), + clusterPositions, + beziers, + edgeLabels, + edgeArrows, + entityFanOut, + }); +``` + +Two problems: + +- **Nothing on the main thread reads `frame.settled`.** A repo‑wide search for + `.settled` access returns no consumer; `WorkerConnection` just stores the frame + and the `Scene` never inspects it. So the renderer has no explicit "this is the + final frame of a settle" signal to hang elision on. +- **`settled` only reflects _cluster_ layouts** (`#anyClusterLayoutRunning()`). In + `flat-force`/`community-force` there are no cluster layouts, so `settled` is + `true` on **every** flat frame — including the very first, mid‑settle frame — so + it would be actively misleading if consumed as‑is. + +Today frame elision works only implicitly (the worker stops posting frames when +the scheduler halts). That's fine for "idle", but it gives the renderer no way to, +say, do a final high‑quality pass once, drop to a cheaper per‑frame path while +moving, or stop the R7 React churn precisely at convergence. + +**Impact.** No wasted work _today_, but a missing lever that several other fixes +(R1 gating, R7 diffing) would use. Also a latent correctness trap if someone wires +the current `settled` into the renderer. + +**Fix.** Make `settled` mean "no layout (cluster **or** entity/flat) is still +running" — i.e. base it on `#anyLayoutRunning()` — and have the `Scene` use it to +(a) run one final `#pushLayers`/label pass and then (b) stop bumping `positionTick` +/ stop the R7 emit until the next change. Add a test that a settled flat graph +emits `settled: true` exactly once and then goes quiet. + +--- + +## 4. GPU buffer & texture management + +### R3 — Community BubbleSets re‑group on the CPU and recreate the GPU texture every frame — **High** (community‑force) + +**Where.** `render/community.ts` `communityLayer` runs inside `#buildDataLayers`, +so once per position frame. It re‑groups all nodes by community and re‑gathers a +positions texture every time: + +```62:75:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts + // Group node indices by community; keep only the non-trivial ones. + const byCommunity = new Map(); + for (let idx = 0; idx < graph.count; idx++) { + const community = membership[idx] ?? -1; + if (community < 0) { + continue; + } + const members = byCommunity.get(community); + if (members) { + members.push(idx); + } else { + byCommunity.set(community, [idx]); + } + } +``` + +A **new** `positions: Float32Array` is allocated and filled each call +(`community.ts:88‑123`), then handed to a new `BubbleSetSDFLayer`. Because +`props.positions` is a new array identity every frame, the layer destroys and +recreates its GPU texture every frame: + +```222:229:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts + if ( + props.positions !== oldProps.positions || + props.texWidth !== oldProps.texWidth || + props.texHeight !== oldProps.texHeight + ) { + this._updateTexture(); + } +``` + +```256:266:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts + _updateTexture() { + const state = this.state as BubbleLayerState; + state.texture?.destroy(); + state.texture = this.context.device.createTexture({ + format: "rg32float", + width: this.props.texWidth, + height: this.props.texHeight, + data: this.props.positions, + sampler: { minFilter: "nearest", magFilter: "nearest" }, + }); + } +``` + +The module's own header (`community.ts:11‑19`) already flags this as a `PERF TODO`. + +**Why it hurts.** Per settling frame in community‑force: an O(nodes) `Map`‑of‑arrays +regroup + bbox recompute on the main thread, plus a **GPU texture +allocate + upload + destroy** of up to `256 × ceil(N/256)` rg32float texels. +Per‑frame texture create/destroy is one of the most allocator‑hostile things you +can do to a WebGL driver and can stall the pipeline. + +**Impact.** High in community‑force while the stress layout settles; scales with +node count. + +**Fix (matches the code's own TODO).** + +- Build a **stable per‑community index list** (`[offset, count]` into an index + buffer of SAB node indices), rebuilt only when communities change (a Louvain + rerun — which already rides the structure frame, `RenderFlatGraph.communities`). + Have the shader read SAB positions directly through that index (stride‑aware, + since the SAB is interleaved) so no positions texture is re‑gathered per frame. +- Keep the texture (or SAB binding) **stable across frames**; only re‑upload on a + community change, not on a position tick. +- Compute the per‑community bbox from the index list (cheap O(nodes) min/max) or + as a GPU reduction. If all else fails, move the grouping + bbox into the worker + and ride the frame. + +--- + +### R4 — Hierarchical leaf edges are gathered on the main thread and re‑uploaded every frame — **Medium** (hierarchical‑lod) + +**Where.** `render/clusters.ts` `clusterEntityLayers` runs per position frame and, +for each open leaf, allocates fresh `src`/`dst` arrays and reads node positions out +of the SAB on the main thread for every internal edge and every fan‑out feeder: + +```218:237:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts + if (internalCount > 0) { + const src = new Float32Array(internalCount * 2); + const dst = new Float32Array(internalCount * 2); + const colors = dimActive ? new Uint8Array(internalCount * 4) : undefined; + + for (let edge = 0; edge < internalCount; edge++) { + const left = layer.internalEdges[edge * 2]!; + const right = layer.internalEdges[edge * 2 + 1]!; + + src[edge * 2] = leafNodeX(cluster.positions, left); + src[edge * 2 + 1] = leafNodeY(cluster.positions, left); + dst[edge * 2] = leafNodeX(cluster.positions, right); + dst[edge * 2 + 1] = leafNodeY(cluster.positions, right); +``` + +These feed a `LineLayer` whose `data` is a brand‑new binary object each frame +(`clusters.ts:239‑265`), so all endpoint buffers are re‑uploaded every frame. + +**Why it hurts.** The entity **dots** avoid this — they bind the SAB directly as a +stride/offset binary attribute (`clusters.ts:271‑296`, `leafPositionAttribute`) so +the GPU reads them with zero per‑frame CPU. But `LineLayer` needs _paired_ +endpoints, and the SAB stores only node positions, so the pairing is recomputed on +the CPU each frame: O(internalEdges + fanOut) per open leaf, plus the throwaway +allocations and full re‑upload. With several open leaves and dense internal +topology this is the bulk of the hierarchical per‑frame CPU (compounding R1). + +**Impact.** Medium; scales with (open leaves × edges/leaf) and only in the +hierarchical tier while a leaf/macro is moving. + +**Fix.** + +- **Reuse endpoint buffers.** Keep per‑leaf `src`/`dst`/`colors` `Float32Array`s + sized to the edge count (they only change on a structure frame), and each + position frame overwrite in place + bump a `positionTick` `updateTrigger`, + mirroring the bubble‑layer pattern. Avoids per‑frame allocation and lets deck + re‑upload without rebuilding the layer. +- **Or move edge endpoints into a GPU‑side gather.** A custom line layer that + reads the two endpoints from the SAB by index (like `BezierSDFLayer` reads + interleaved control points) would eliminate the CPU pairing entirely — at the + cost of a small custom shader. +- **Or skip re‑gather for settled leaves.** If the per‑leaf "moved" bit (R1 fix 2) + is available, only re‑gather leaves whose layout actually ticked. + +### R‑note — Transferred buffers necessarily mean new attributes (not a bug) + +For completeness: the flat dots (`flat-dots.ts`), the bézier edges (`edges.ts`), +and cluster positions ride buffers that the worker **transfers** to the main +thread each frame (`worker/entry.ts` `postPositions`), so a frame's buffers are +consumed once and a new typed‑array view is unavoidable. `edges.ts` already caches +the derived underlay color attribute by `WeakMap` +(`edges.ts:13‑26`), which is the right move. The waste to attack is R1/R3/R4 +(rebuilding _layers_ and re‑gathering _derived_ data), not the transferred buffers +themselves. + +--- + +## 5. Shaders & overdraw + +Both custom SDF layers are elegant and produce crisp results, but both are +**fill‑rate heavy** and drawn with generous bounding quads that overlap. Overdraw +(fragments shaded multiple times) is the thing to watch; it scales with on‑screen +edge/community density and with zoom‑in (bigger quads = more fragments). + +### R5 — Hierarchical edges run an expensive per‑pixel curve solve, twice — **Medium** (hierarchical‑lod) + +**Where.** `render/edges.ts` draws hierarchical edges as **two** stacked +`BezierSDFLayer`s — a wider underlay and the main stroke: + +```98:119:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts + layers.push( + new BezierSDFLayer({ + id: "edges-underlay", + data: bezierData(beziers, underlayColorAttribute(beziers)), + pickable: false, + boundsPaddingPixels: 10, + widthUnits: "common", + widthScale: widthScale * 1.65, + parameters, + }), + ); + layers.push( + new BezierSDFLayer({ + id: "hierarchical-edges", + data: bezierData(beziers, beziers.colors), + ... +``` + +Each fragment evaluates the cubic distance with a 24‑sample coarse search + 5 +Newton iterations: + +```208:241:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bezier-sdf-layer.ts +float distToCubicBezier(vec2 p, vec2 a, vec2 b, vec2 c, vec2 d) { + // Coarse search: 24 uniform samples. + ... + for (int i = 0; i <= 24; i++) { + ... + } + // Newton refinement: 5 iterations. + ... + for (int i = 0; i < 5; i++) { + ... + } +``` + +**Why it hurts.** Every covered fragment does ~25 cubic evaluations + 5 +derivative‑based refinements — and the underlay means each edge's neighborhood is +shaded **twice**. Each instance's quad is the control‑point bbox padded by +`0.5·width + boundsPaddingPixels` (`bezier-sdf-layer.ts:126‑133`), so adjacent / +bundled highways' quads overlap heavily → high overdraw. The same shader also runs +in the **picking** pass (`picking_filterPickingColor` at `bezier-sdf-layer.ts:273`). + +**Impact.** Medium; grows with visible edge count and zoom. On a dense hierarchical +view with many highways this can dominate GPU frame time. + +**Fix (options, by effort).** + +- **Cheapest:** drop the coarse sample count. 24 samples + 5 Newton is generous; + cubic Béziers with these near‑straight control nets converge from far fewer + seeds. Try 8–12 coarse samples + 3 Newton and compare visually — likely + indistinguishable, ~2× cheaper. +- **Fold the underlay into one pass.** Instead of a second full layer, render the + halo in the same fragment shader (compute the SDF once, output the wide/soft + color where `dist` is in the underlay band and the crisp color where it's in the + core band). Halves the bézier evaluations for the underlay look. +- **LOD the solver.** At low zoom, edges are ~1px; a straight‑segment + approximation (or the existing `LineLayer` path used for the flat tier, + `edges.ts:85‑95`) is visually identical there. Switch to SDF only when an edge is + wide enough on screen to show curvature. +- Tighten `boundsPaddingPixels` (10 for the underlay) to cut fragment coverage. + +### R9 — Community metaball shader loops up to 256 nodes per pixel — **Medium** (community‑force) + +**Where.** `render/gpu/bubble-set-sdf-layer.ts` fragment shader sums a metaball +kernel over the community's nodes, per pixel: + +```110:120:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts +void main(void) { + // Sum the finite-support metaball kernel over this community's node centres. + float field = 0.0; + for (int i = 0; i < ${MAX_NODES_PER_COMMUNITY}; i++) { + if (i >= vCount) { + break; + } + int idx = vOffset + i; + vec2 nodePos = + texelFetch(positionsTex, ivec2(idx % bubble.texWidth, idx / bubble.texWidth), 0).rg; + float d = distance(vWorldPos, nodePos) / bubble.fieldRadius; +``` + +**Why it hurts.** Each community is one instanced quad covering its (padded) world +bbox; every fragment in that quad loops over up to `MAX_NODES_PER_COMMUNITY = 256` +texel fetches + distance computes. Big communities → big quads → millions of +fragments each doing up to 256 iterations, and communities' bboxes overlap → +overdraw on top. This is `O(pixels × nodes_per_community)` per frame. + +**Impact.** Medium; worst when zoomed out with several large overlapping +communities filling the viewport. + +**Fix.** + +- **Bound the per‑pixel work spatially.** A coarse uniform grid / bin of each + community's nodes (built when membership changes) lets the shader fetch only the + handful of nodes near the fragment instead of all 256. Even a per‑community + spatial hash cuts the inner loop dramatically. +- **Clamp quad size / tile large communities** so the padded bbox isn't shading + huge empty regions at threshold. +- Combined with R3, the shader would read a stable index range + SAB positions, so + this becomes the only per‑frame community cost. + +--- + +## 6. Picking & misc + +### R10 — Bubble hover triggers a second full picking render — **Low** (hierarchical‑lod) + +**Where.** Because edges render _under_ bubbles but must still win a hover/click, +`#edgePickFor` issues an extra `pickObject` when the top pick is a bubble: + +```1025:1040:apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts + #edgePickFor(info: PickingInfo): PickingInfo | null { + if (isPickableEdgeLayer(info.layer?.id)) { + return info; + } + const overBubble = + (info.object as PlacedCluster | undefined)?.cluster !== undefined; + if (!overBubble) { + return null; + } + return this.#deck.pickObject({ + x: info.x, + y: info.y, + radius: 4, + layerIds: [...PICKABLE_EDGE_LAYER_IDS], + }); + } +``` + +**Why it hurts.** `pickObject` renders the pickable layers to an offscreen buffer. +The edge layers use the expensive SDF shader (R5), which therefore also runs in +this pick pass. It's scissored to a 4px radius and only fires while hovering a +bubble, so cost is bounded — but a hover that sits on a bubble re‑picks every hover +event. + +**Impact.** Low. Only mention because it multiplies with R5's shader cost. + +**Fix.** Debounce/skip the secondary pick when the cursor hasn't moved beyond the +pick radius; or precompute a cheap edge hit‑test (segment distance) on the CPU from +the bezier endpoints already on the frame, avoiding a GPU pick pass for edges under +bubbles. + +### R11 — Small per‑frame misc — **Low** + +- **`edgeArrowLayer` split cache misses every frame.** `splitEdgeArrows` memoizes + by `WeakMap` (`edge-arrows.ts:17‑20, 39‑58`), but + `positions.edgeArrows` is a **new** array each frame, so it's a guaranteed cache + miss — the `WeakMap` never helps. Either drop the cache (it's misleading) or key + it off the frame `version`. Cost is tiny (a couple of small array partitions). +- **`TextLayer` `characterSet:"auto"`** on cluster/edge labels re‑derives the glyph + set when `data` identity changes (every rebuild — see R6). An explicit + `characterSet` (or persistent layers) avoids it. +- **Selection/overlay layers** are rebuilt every frame even when empty + (`selection.ts` `selectionOverlayLayers` returns two `ScatterplotLayer`s with 0 + data). Harmless (keeps the layer set stable) but part of the R1 per‑frame layer + churn; folds into the R1 fix. + +--- + +## 7. How to verify + +None of this needs a build; it can be measured in the running app: + +- **Per‑frame CPU (R1, R2, R4, R6, R7):** Chrome DevTools Performance profile + while (a) a large result set streams in and (b) a big leaf settles. Look for + repeated `#buildDataLayers` / `clusterEntityLayers` / `#rebuildEntityIconData` + self‑time and GC sawtooth. For R2 specifically, add a counter to + `resolveEntityIcon` and watch it climb ≈ `dots × structureFrames`. +- **deck.gl draw/redraw counts:** `deck.metrics` exposes `fps`, + `setPropsTime`, `updateAttributesTime`, and draw counts; log it per second, or + pass `_onMetrics`. Confirm draws go to ~0 when idle (validating §0) and watch + `updateAttributesTime` while settling (R1) and per zoom event (R6). +- **GPU texture churn (R3):** in a WebGL trace (Spector.js) capture a settling + community‑force frame and confirm a `texImage`/texture‑create per frame on the + bubble layer; it should disappear after the R3 fix. +- **Overdraw / fill (R5, R9):** Spector.js frame capture → inspect fragment counts + for `edges-underlay` + `hierarchical-edges` and `flat-bubbles`; or temporarily + shrink `boundsPaddingPixels` / the metaball loop bound and measure FPS delta when + zoomed in on a dense view. +- **`settled` (R8):** log `frame.settled` in `WorkerConnection.#handleMessage` + `POSITIONS_FRAME`; today it's `true` on the first flat frame — that's the bug to + confirm before wiring it to anything. + +--- + +## 8. Relationship to `worker/PERFORMANCE.md` + +The worker doc's **F1/F2** (no incremental/no‑op commit; streaming re‑does +whole‑graph work per batch) are the _upstream_ of **R2** here: fewer/cheaper +structure commits directly reduce main‑thread re‑resolution. The worker's +**F6/F12** (per‑tick `#emitPositions` allocation, `snapshot()` copies, port/route +recompute) are the _upstream_ of **R1/R4**: the frame's payload is rebuilt in the +worker and then rebuilt again on the main thread. Fixing the coordination +(coalesced commits, a real `settled` signal, per‑layout "moved" bits) benefits +both halves at once. diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts index 20314c0d59a..ea147f8b9d2 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts @@ -706,6 +706,7 @@ export class GraphWorker { this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId }); } } + this.#forceLayouts.clear(); this.#layoutKind.clear(); this.#entityPortTargets.clear(); @@ -847,7 +848,6 @@ export class GraphWorker { readonly rebuildTree?: boolean; }): void { this.recomputeMode(); - this.#publishEntityIdMapOnce(); // Flat tiers render the whole entity set as one entity graph. Only @@ -856,6 +856,7 @@ export class GraphWorker { if (this.#hierarchicalActive) { this.#tearDownHierarchical(); } + this.#hierarchicalActive = false; this.#commitFlat(); return; @@ -872,6 +873,7 @@ export class GraphWorker { } else if (opts?.deltas && opts.deltas.length > 0) { this.updateClusters(opts.deltas); } + this.#hierarchicalActive = true; if (!this.hasClusters) { @@ -1101,6 +1103,7 @@ export class GraphWorker { if (this.#entityIdMapPublished) { return; } + const map = this.#entities.lookupBuffer; this.#onLayoutMessage?.({ type: "ENTITY_ID_MAP", @@ -1460,6 +1463,7 @@ export class GraphWorker { clusterId: FLAT_LAYOUT_ID, }); } + this.#flatBuffer = undefined; this.#flatRenderEdges = []; this.#flatLinkCount = -1; @@ -1477,6 +1481,7 @@ export class GraphWorker { this.#onLayoutMessage?.({ type: "LAYOUT_DESTROYED", clusterId: id }); } } + this.#forceLayouts.clear(); this.#layoutKind.clear(); this.#entityPortTargets.clear(); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts new file mode 100644 index 00000000000..58e632cad6e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts @@ -0,0 +1,983 @@ +/* + * GUARANTEED axis-aligned rectangle overlap removal via the "Fast Node Overlap + * Removal" method (Tim Dwyer, Kim Marriott, Peter J. Stuckey, Graph Drawing + * 2006). Given rectangle centres and half-extents it moves the centres the + * minimum weighted squared distance to a configuration with no pairwise + * overlap, using the standard two independent passes: + * + * 1. Generate x-separation constraints with a scanline over rectangles sorted + * by centre-x, connecting neighbours that overlap in y; solve 1-D VPSC on x. + * 2. Regenerate y-separation constraints between rectangles that STILL overlap + * in x after pass 1; solve 1-D VPSC on y. + * + * Because pass 2 only moves y (leaving pass-1 x separation intact) every pair + * ends up separated in x or y, so no rectangles overlap. + * + * The 1-D VPSC solver is the block-based satisfy / merge / split quadratic + * program from the paper. This is a faithful re-implementation of WebCola's + * `vpsc.ts` / `rectangle.ts` (Constraint / Variable / Block / Blocks / Solver + * and the scanline constraint generation) but expressed entirely over reused + * typed arrays with index handles instead of per-node object allocation, and + * with the recursive block traversals rewritten iteratively so it can run every + * layout tick on large graphs without GC churn or stack-depth limits. + * + * Determinism: rectangles are ordered by (centre, index) in the scanline (the + * index tie-break also makes coincident centres distinct, which a plain + * comparator would collapse) and the balancing treap uses index-hashed + * priorities, so identical input yields identical output. + * + * All weights/scales are 1 (uniform importance), which is all overlap removal + * needs, so the PositionStats reduce to block posn = (Σdesired − Σoffset) / n. + */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-bitwise */ +/* eslint-disable id-length */ + +const MIN_SEP = 1e-6; +const ZERO_UPPERBOUND = -1e-10; +const LAGRANGIAN_TOLERANCE = -1e-4; +const COST_TOLERANCE = 1e-4; + +/** Bijective-ish 32-bit hash for deterministic, well-spread treap priorities. */ +function hashU32(value: number): number { + let x = value | 0; + x = (x + 0x7ed55d16 + (x << 12)) | 0; + x = x ^ 0xc761c23c ^ (x >>> 19); + x = (x + 0x165667b1 + (x << 5)) | 0; + x = (x + 0xd3a2646c) ^ (x << 9); + x = (x + 0xfd7046c5 + (x << 3)) | 0; + x = x ^ 0xb55a4f09 ^ (x >>> 16); + return x >>> 0; +} + +export class VpscOverlapRemover { + #capacity = 0; + #conCapacity = 0; + #n = 0; + #numCon = 0; + + // Rectangle inputs for the current pass (owned by the caller, mutated in place). + #gx: Float32Array = new Float32Array(0); + #gy: Float32Array = new Float32Array(0); + #ghalfW: Float32Array = new Float32Array(0); + #ghalfH: Float32Array = new Float32Array(0); + + // Which centre coordinate the scanline is ordered by this pass (x or y array). + #slCenter: Float32Array = new Float32Array(0); + + // Per-variable VPSC state. + #desired = new Float64Array(0); + #offset = new Float64Array(0); + #varBlock = new Int32Array(0); + #varNext = new Int32Array(0); + + // Per-block VPSC state (blocks are index handles into these arrays). + #blockPosn = new Float64Array(0); + #blockSumDesired = new Float64Array(0); + #blockSumOffset = new Float64Array(0); + #blockCount = new Int32Array(0); + #blockHead = new Int32Array(0); + #blockListIndex = new Int32Array(0); + #blockAlive = new Uint8Array(0); + #blocksList = new Int32Array(0); + #blocksLen = 0; + #freeBlocks = new Int32Array(0); + #freeTop = 0; + + // Constraint arrays (grown on demand; a heavy pile-up can be O(n^2)). + #conLeft = new Int32Array(0); + #conRight = new Int32Array(0); + #conGap = new Float64Array(0); + #conActive = new Uint8Array(0); + #conLm = new Float64Array(0); + + // CSR adjacency: constraints incident to each variable as left / right endpoint. + #outOffsets = new Int32Array(0); + #outCons = new Int32Array(0); + #inOffsets = new Int32Array(0); + #inCons = new Int32Array(0); + #csrCursor = new Int32Array(0); + + // Inactive-constraint working set for `mostViolated` (swap-pop membership). + #inactive = new Int32Array(0); + #inactiveLen = 0; + + // Block-traversal scratch (iterative compute_lm / split), reused across calls. + #stack = new Int32Array(0); + #order = new Int32Array(0); + #orderLen = 0; + #parentVar = new Int32Array(0); + #parentCon = new Int32Array(0); + #subtree = new Float64Array(0); + #visited = new Int32Array(0); + #visitStamp = 0; + + // Scanline balancing treap (keyed by (#slCenter, index); parent-linked). + #slLeft = new Int32Array(0); + #slRight = new Int32Array(0); + #slParent = new Int32Array(0); + #slPriority = new Uint32Array(0); + #slRoot = -1; + + // Event schedule for the scanline sweep (open/close of each rectangle). + #eventOrder = new Int32Array(0); + #eventPos = new Float64Array(0); + + #minLm = 0; + + constructor(capacity: number) { + this.#allocateNode(Math.max(1, capacity | 0)); + this.#allocateConstraints(Math.max(16, capacity | 0)); + } + + /** + * Move rectangle centres `x`/`y` to the nearest overlap-free configuration. + * `halfW`/`halfH` are half-extents; only the first `n` entries are used and + * `x`/`y` are mutated in place. + */ + removeOverlaps( + x: Float32Array, + y: Float32Array, + halfW: Float32Array, + halfH: Float32Array, + n: number, + ): void { + if (n <= 1) { + return; + } + this.#ensureNodeCapacity(n); + this.#n = n; + this.#gx = x; + this.#gy = y; + this.#ghalfW = halfW; + this.#ghalfH = halfH; + + this.#solveDimension(true); + this.#solveDimension(false); + } + + #solveDimension(isX: boolean): void { + const coords = isX ? this.#gx : this.#gy; + this.#slCenter = coords; + this.#generateConstraints(isX); + this.#buildCsr(); + + const n = this.#n; + for (let i = 0; i < n; i++) { + this.#desired[i] = coords[i]!; + } + this.#initBlocks(); + this.#solve(); + for (let i = 0; i < n; i++) { + coords[i] = this.#blockPosn[this.#varBlock[i]!]! + this.#offset[i]!; + } + } + + // --------------------------------------------------------------------------- + // Scanline constraint generation + // --------------------------------------------------------------------------- + + #generateConstraints(isX: boolean): void { + const n = this.#n; + this.#numCon = 0; + this.#slRoot = -1; + + const orthoLow = isX ? this.#gy : this.#gx; + const orthoHalf = isX ? this.#ghalfH : this.#ghalfW; + const events = this.#eventOrder; + const eventPos = this.#eventPos; + for (let i = 0; i < n; i++) { + events[i] = i; + events[i + n] = i + n; + eventPos[i] = orthoLow[i]! - orthoHalf[i]!; + eventPos[i + n] = orthoLow[i]! + orthoHalf[i]!; + } + + const eventCount = 2 * n; + events.subarray(0, eventCount).sort((a, b) => { + const pa = eventPos[a]!; + const pb = eventPos[b]!; + if (pa < pb) { + return -1; + } + if (pa > pb) { + return 1; + } + // Opens (id < n) precede closes at equal position; then stable by id. + const aOpen = a < n ? 0 : 1; + const bOpen = b < n ? 0 : 1; + if (aOpen !== bOpen) { + return aOpen - bOpen; + } + return a - b; + }); + + for (let e = 0; e < eventCount; e++) { + const event = events[e]!; + if (event < n) { + this.#treapInsert(event); + if (isX) { + this.#findXNeighbours(event); + } else { + this.#findYNeighbours(event); + } + } else { + this.#treapRemove(event - n); + } + } + } + + #overlapX(u: number, v: number): number { + const ucx = this.#gx[u]!; + const vcx = this.#gx[v]!; + if ( + ucx <= vcx && + this.#gx[v]! - this.#ghalfW[v]! < this.#gx[u]! + this.#ghalfW[u]! + ) { + return ( + this.#gx[u]! + this.#ghalfW[u]! - (this.#gx[v]! - this.#ghalfW[v]!) + ); + } + if ( + vcx <= ucx && + this.#gx[u]! - this.#ghalfW[u]! < this.#gx[v]! + this.#ghalfW[v]! + ) { + return ( + this.#gx[v]! + this.#ghalfW[v]! - (this.#gx[u]! - this.#ghalfW[u]!) + ); + } + return 0; + } + + #overlapY(u: number, v: number): number { + const ucy = this.#gy[u]!; + const vcy = this.#gy[v]!; + if ( + ucy <= vcy && + this.#gy[v]! - this.#ghalfH[v]! < this.#gy[u]! + this.#ghalfH[u]! + ) { + return ( + this.#gy[u]! + this.#ghalfH[u]! - (this.#gy[v]! - this.#ghalfH[v]!) + ); + } + if ( + vcy <= ucy && + this.#gy[u]! - this.#ghalfH[u]! < this.#gy[v]! + this.#ghalfH[v]! + ) { + return ( + this.#gy[v]! + this.#ghalfH[v]! - (this.#gy[u]! - this.#ghalfH[u]!) + ); + } + return 0; + } + + #emitConstraint(left: number, right: number, isX: boolean): void { + const half = isX ? this.#ghalfW : this.#ghalfH; + const gap = half[left]! + half[right]! + MIN_SEP; + const con = this.#numCon; + if (con + 1 > this.#conCapacity) { + this.#growConstraints(con + 1); + } + this.#conLeft[con] = left; + this.#conRight[con] = right; + this.#conGap[con] = gap; + this.#numCon = con + 1; + } + + /** Constraints to every scanline neighbour whose cheaper resolution is in x. */ + #findXNeighbours(v: number): void { + let u = this.#treapSuccessor(v); + while (u !== -1) { + const ox = this.#overlapX(u, v); + if (ox <= 0 || ox <= this.#overlapY(u, v)) { + this.#emitConstraint(v, u, true); + } + if (ox <= 0) { + break; + } + u = this.#treapSuccessor(u); + } + u = this.#treapPredecessor(v); + while (u !== -1) { + const ox = this.#overlapX(u, v); + if (ox <= 0 || ox <= this.#overlapY(u, v)) { + this.#emitConstraint(u, v, true); + } + if (ox <= 0) { + break; + } + u = this.#treapPredecessor(u); + } + } + + /** A y-constraint to each immediate y-neighbour that still overlaps in x. */ + #findYNeighbours(v: number): void { + const succ = this.#treapSuccessor(v); + if (succ !== -1 && this.#overlapX(succ, v) > 0) { + this.#emitConstraint(v, succ, false); + } + const pred = this.#treapPredecessor(v); + if (pred !== -1 && this.#overlapX(pred, v) > 0) { + this.#emitConstraint(pred, v, false); + } + } + + // --------------------------------------------------------------------------- + // Scanline treap (ordered by (#slCenter, index), max-heap on hashed priority) + // --------------------------------------------------------------------------- + + #slLess(a: number, b: number): boolean { + const ca = this.#slCenter[a]!; + const cb = this.#slCenter[b]!; + if (ca < cb) { + return true; + } + if (ca > cb) { + return false; + } + return a < b; + } + + #treapInsert(v: number): void { + this.#slLeft[v] = -1; + this.#slRight[v] = -1; + this.#slParent[v] = -1; + if (this.#slRoot === -1) { + this.#slRoot = v; + return; + } + let cur = this.#slRoot; + let parent = -1; + let goLeft = false; + while (cur !== -1) { + parent = cur; + if (this.#slLess(v, cur)) { + goLeft = true; + cur = this.#slLeft[cur]!; + } else { + goLeft = false; + cur = this.#slRight[cur]!; + } + } + this.#slParent[v] = parent; + if (goLeft) { + this.#slLeft[parent] = v; + } else { + this.#slRight[parent] = v; + } + const priority = this.#slPriority; + while ( + this.#slParent[v] !== -1 && + priority[v]! > priority[this.#slParent[v]!]! + ) { + const up = this.#slParent[v]!; + if (this.#slLeft[up] === v) { + this.#rotateRight(up); + } else { + this.#rotateLeft(up); + } + } + } + + #treapRemove(v: number): void { + while (this.#slLeft[v] !== -1 || this.#slRight[v] !== -1) { + const l = this.#slLeft[v]!; + const r = this.#slRight[v]!; + if ( + r === -1 || + (l !== -1 && this.#slPriority[l]! > this.#slPriority[r]!) + ) { + this.#rotateRight(v); + } else { + this.#rotateLeft(v); + } + } + const parent = this.#slParent[v]!; + if (parent === -1) { + this.#slRoot = -1; + } else if (this.#slLeft[parent] === v) { + this.#slLeft[parent] = -1; + } else { + this.#slRight[parent] = -1; + } + this.#slParent[v] = -1; + } + + #rotateLeft(p: number): void { + const r = this.#slRight[p]!; + const rLeft = this.#slLeft[r]!; + this.#slRight[p] = rLeft; + if (rLeft !== -1) { + this.#slParent[rLeft] = p; + } + const parent = this.#slParent[p]!; + this.#slParent[r] = parent; + if (parent === -1) { + this.#slRoot = r; + } else if (this.#slLeft[parent] === p) { + this.#slLeft[parent] = r; + } else { + this.#slRight[parent] = r; + } + this.#slLeft[r] = p; + this.#slParent[p] = r; + } + + #rotateRight(p: number): void { + const l = this.#slLeft[p]!; + const lRight = this.#slRight[l]!; + this.#slLeft[p] = lRight; + if (lRight !== -1) { + this.#slParent[lRight] = p; + } + const parent = this.#slParent[p]!; + this.#slParent[l] = parent; + if (parent === -1) { + this.#slRoot = l; + } else if (this.#slLeft[parent] === p) { + this.#slLeft[parent] = l; + } else { + this.#slRight[parent] = l; + } + this.#slRight[l] = p; + this.#slParent[p] = l; + } + + #treapSuccessor(v: number): number { + if (this.#slRight[v] !== -1) { + let cur = this.#slRight[v]!; + while (this.#slLeft[cur] !== -1) { + cur = this.#slLeft[cur]!; + } + return cur; + } + let cur = v; + let parent = this.#slParent[v]!; + while (parent !== -1 && this.#slRight[parent] === cur) { + cur = parent; + parent = this.#slParent[parent]!; + } + return parent; + } + + #treapPredecessor(v: number): number { + if (this.#slLeft[v] !== -1) { + let cur = this.#slLeft[v]!; + while (this.#slRight[cur] !== -1) { + cur = this.#slRight[cur]!; + } + return cur; + } + let cur = v; + let parent = this.#slParent[v]!; + while (parent !== -1 && this.#slLeft[parent] === cur) { + cur = parent; + parent = this.#slParent[parent]!; + } + return parent; + } + + // --------------------------------------------------------------------------- + // CSR adjacency + // --------------------------------------------------------------------------- + + #buildCsr(): void { + const n = this.#n; + const numCon = this.#numCon; + const outOffsets = this.#outOffsets; + const inOffsets = this.#inOffsets; + for (let i = 0; i <= n; i++) { + outOffsets[i] = 0; + inOffsets[i] = 0; + } + for (let c = 0; c < numCon; c++) { + outOffsets[this.#conLeft[c]! + 1]! += 1; + inOffsets[this.#conRight[c]! + 1]! += 1; + } + for (let i = 0; i < n; i++) { + outOffsets[i + 1]! += outOffsets[i]!; + inOffsets[i + 1]! += inOffsets[i]!; + } + const cursor = this.#csrCursor; + for (let i = 0; i < n; i++) { + cursor[i] = outOffsets[i]!; + } + for (let c = 0; c < numCon; c++) { + this.#outCons[cursor[this.#conLeft[c]!]!++] = c; + } + for (let i = 0; i < n; i++) { + cursor[i] = inOffsets[i]!; + } + for (let c = 0; c < numCon; c++) { + this.#inCons[cursor[this.#conRight[c]!]!++] = c; + } + } + + // --------------------------------------------------------------------------- + // Block bookkeeping + // --------------------------------------------------------------------------- + + #initBlocks(): void { + const n = this.#n; + this.#blocksLen = 0; + this.#freeTop = 0; + for (let b = this.#capacity + 1; b >= n; b--) { + this.#freeBlocks[this.#freeTop++] = b; + } + for (let i = 0; i < n; i++) { + this.#offset[i] = 0; + this.#varBlock[i] = i; + this.#varNext[i] = -1; + this.#blockHead[i] = i; + this.#blockCount[i] = 1; + this.#blockSumDesired[i] = this.#desired[i]!; + this.#blockSumOffset[i] = 0; + this.#blockPosn[i] = this.#desired[i]!; + this.#insertBlock(i); + } + const numCon = this.#numCon; + for (let c = 0; c < numCon; c++) { + this.#conActive[c] = 0; + this.#inactive[c] = c; + } + this.#inactiveLen = numCon; + } + + #insertBlock(b: number): void { + this.#blockListIndex[b] = this.#blocksLen; + this.#blocksList[this.#blocksLen++] = b; + this.#blockAlive[b] = 1; + } + + #destroyBlock(b: number): void { + const last = --this.#blocksLen; + const swap = this.#blocksList[last]!; + const at = this.#blockListIndex[b]!; + this.#blocksList[at] = swap; + this.#blockListIndex[swap] = at; + this.#blockAlive[b] = 0; + this.#freeBlocks[this.#freeTop++] = b; + } + + #allocBlock(): number { + return this.#freeBlocks[--this.#freeTop]!; + } + + #addVarToBlock(b: number, v: number): void { + this.#varBlock[v] = b; + this.#varNext[v] = this.#blockHead[b]!; + this.#blockHead[b] = v; + this.#blockCount[b]! += 1; + this.#blockSumDesired[b]! += this.#desired[v]!; + this.#blockSumOffset[b]! += this.#offset[v]!; + } + + #position(v: number): number { + return this.#blockPosn[this.#varBlock[v]!]! + this.#offset[v]!; + } + + #dfdv(v: number): number { + return 2 * (this.#position(v) - this.#desired[v]!); + } + + #slack(c: number): number { + return ( + this.#position(this.#conRight[c]!) - + this.#conGap[c]! - + this.#position(this.#conLeft[c]!) + ); + } + + // --------------------------------------------------------------------------- + // Solver (mirrors WebCola Solver.solve / satisfy / mostViolated + Blocks.split) + // --------------------------------------------------------------------------- + + #solve(): void { + this.#satisfy(); + let cost = this.#cost(); + let lastCost = Number.MAX_VALUE; + let guard = 0; + const maxOuter = 4 * this.#n + 16; + while (Math.abs(lastCost - cost) > COST_TOLERANCE && guard++ < maxOuter) { + this.#satisfy(); + lastCost = cost; + cost = this.#cost(); + } + } + + #cost(): number { + const n = this.#n; + let sum = 0; + for (let v = 0; v < n; v++) { + const d = this.#position(v) - this.#desired[v]!; + sum += d * d; + } + return sum; + } + + #satisfy(): void { + this.#splitBlocks(); + const maxInner = 8 * (this.#numCon + this.#n) + 64; + let guard = 0; + while (guard++ < maxInner) { + const v = this.#mostViolated(); + if ( + v === -1 || + !(this.#slack(v) < ZERO_UPPERBOUND && this.#conActive[v] === 0) + ) { + break; + } + const lb = this.#varBlock[this.#conLeft[v]!]!; + const rb = this.#varBlock[this.#conRight[v]!]!; + if (lb !== rb) { + this.#merge(v); + } else { + const c = this.#findMinLMBetween(this.#conLeft[v]!, this.#conRight[v]!); + if (c === -1) { + continue; + } + this.#blockSplit(c); + this.#destroyBlock(lb); + this.#inactive[this.#inactiveLen++] = c; + if (this.#slack(v) >= 0) { + this.#inactive[this.#inactiveLen++] = v; + } else { + this.#merge(v); + } + } + } + } + + #mostViolated(): number { + let minSlack = Number.MAX_VALUE; + let found = -1; + let deletePoint = this.#inactiveLen; + const inactive = this.#inactive; + for (let i = 0; i < this.#inactiveLen; i++) { + const c = inactive[i]!; + const slack = this.#slack(c); + if (slack < minSlack) { + minSlack = slack; + found = c; + deletePoint = i; + } + } + if ( + deletePoint !== this.#inactiveLen && + minSlack < ZERO_UPPERBOUND && + this.#conActive[found] === 0 + ) { + inactive[deletePoint] = inactive[--this.#inactiveLen]!; + } + return found; + } + + #merge(c: number): void { + const left = this.#conLeft[c]!; + const right = this.#conRight[c]!; + const lb = this.#varBlock[left]!; + const rb = this.#varBlock[right]!; + const dist = this.#offset[right]! - this.#offset[left]! - this.#conGap[c]!; + if (this.#blockCount[lb]! < this.#blockCount[rb]!) { + this.#mergeInto(rb, lb, c, dist); + } else { + this.#mergeInto(lb, rb, c, -dist); + } + } + + #mergeInto(target: number, source: number, c: number, dist: number): void { + this.#conActive[c] = 1; + let v = this.#blockHead[source]!; + while (v !== -1) { + const next = this.#varNext[v]!; + this.#offset[v]! += dist; + this.#addVarToBlock(target, v); + v = next; + } + this.#blockPosn[target] = + (this.#blockSumDesired[target]! - this.#blockSumOffset[target]!) / + this.#blockCount[target]!; + this.#destroyBlock(source); + } + + #splitBlocks(): void { + const snapshotLen = this.#blocksLen; + const snapshot = this.#stack; + for (let i = 0; i < snapshotLen; i++) { + snapshot[i] = this.#blocksList[i]!; + } + for (let i = 0; i < snapshotLen; i++) { + const b = snapshot[i]!; + if (this.#blockAlive[b] === 0) { + continue; + } + const c = this.#findMinLM(b); + if (c !== -1 && this.#minLm < LAGRANGIAN_TOLERANCE) { + const owner = this.#varBlock[this.#conLeft[c]!]!; + this.#blockSplit(c); + this.#destroyBlock(owner); + this.#inactive[this.#inactiveLen++] = c; + } + } + } + + #blockSplit(c: number): void { + this.#conActive[c] = 0; + this.#createSplitBlock(this.#conLeft[c]!); + this.#createSplitBlock(this.#conRight[c]!); + } + + /** Rebuild the connected component of active constraints reachable from `start`. */ + #createSplitBlock(start: number): void { + const b = this.#allocBlock(); + this.#blockHead[b] = -1; + this.#blockCount[b] = 0; + this.#blockSumDesired[b] = 0; + this.#blockSumOffset[b] = 0; + this.#offset[start] = 0; + + const stamp = ++this.#visitStamp; + this.#visited[start] = stamp; + this.#addVarToBlock(b, start); + + let stackLen = 0; + this.#order[stackLen++] = start; + while (stackLen > 0) { + const v = this.#order[--stackLen]!; + stackLen = this.#visitSplitNeighbours(v, b, stamp, stackLen); + } + + this.#blockPosn[b] = + (this.#blockSumDesired[b]! - this.#blockSumOffset[b]!) / + this.#blockCount[b]!; + } + + #visitSplitNeighbours( + v: number, + b: number, + stamp: number, + stackLenIn: number, + ): number { + let stackLen = stackLenIn; + const outStart = this.#outOffsets[v]!; + const outEnd = this.#outOffsets[v + 1]!; + for (let k = outStart; k < outEnd; k++) { + const c = this.#outCons[k]!; + if (this.#conActive[c] === 0) { + continue; + } + const next = this.#conRight[c]!; + if (this.#visited[next] === stamp) { + continue; + } + this.#visited[next] = stamp; + this.#offset[next] = this.#offset[v]! + this.#conGap[c]!; + this.#addVarToBlock(b, next); + this.#order[stackLen++] = next; + } + const inStart = this.#inOffsets[v]!; + const inEnd = this.#inOffsets[v + 1]!; + for (let k = inStart; k < inEnd; k++) { + const c = this.#inCons[k]!; + if (this.#conActive[c] === 0) { + continue; + } + const next = this.#conLeft[c]!; + if (this.#visited[next] === stamp) { + continue; + } + this.#visited[next] = stamp; + this.#offset[next] = this.#offset[v]! - this.#conGap[c]!; + this.#addVarToBlock(b, next); + this.#order[stackLen++] = next; + } + return stackLen; + } + + /** + * Iterative compute_lm rooted at `start`: fills #order (preorder), #parentVar, + * #parentCon over the active-constraint spanning tree of the block, then + * accumulates subtree derivatives leaf-to-root to set each constraint's + * Lagrange multiplier (#conLm). + */ + #computeLm(start: number): void { + const stamp = ++this.#visitStamp; + this.#visited[start] = stamp; + this.#parentVar[start] = -1; + this.#parentCon[start] = -1; + let orderLen = 0; + let stackLen = 0; + this.#stack[stackLen++] = start; + while (stackLen > 0) { + const v = this.#stack[--stackLen]!; + this.#order[orderLen++] = v; + stackLen = this.#dfsActiveNeighbours(v, stamp, stackLen); + } + this.#orderLen = orderLen; + + for (let k = 0; k < orderLen; k++) { + const v = this.#order[k]!; + this.#subtree[v] = this.#dfdv(v); + } + for (let k = orderLen - 1; k >= 1; k--) { + const v = this.#order[k]!; + const c = this.#parentCon[v]!; + this.#subtree[this.#parentVar[v]!]! += this.#subtree[v]!; + this.#conLm[c] = + this.#conRight[c] === v ? this.#subtree[v]! : -this.#subtree[v]!; + } + } + + #dfsActiveNeighbours(v: number, stamp: number, stackLenIn: number): number { + let stackLen = stackLenIn; + const parent = this.#parentVar[v]!; + const outStart = this.#outOffsets[v]!; + const outEnd = this.#outOffsets[v + 1]!; + for (let k = outStart; k < outEnd; k++) { + const c = this.#outCons[k]!; + if (this.#conActive[c] === 0) { + continue; + } + const next = this.#conRight[c]!; + if (next === parent || this.#visited[next] === stamp) { + continue; + } + this.#visited[next] = stamp; + this.#parentVar[next] = v; + this.#parentCon[next] = c; + this.#stack[stackLen++] = next; + } + const inStart = this.#inOffsets[v]!; + const inEnd = this.#inOffsets[v + 1]!; + for (let k = inStart; k < inEnd; k++) { + const c = this.#inCons[k]!; + if (this.#conActive[c] === 0) { + continue; + } + const next = this.#conLeft[c]!; + if (next === parent || this.#visited[next] === stamp) { + continue; + } + this.#visited[next] = stamp; + this.#parentVar[next] = v; + this.#parentCon[next] = c; + this.#stack[stackLen++] = next; + } + return stackLen; + } + + #findMinLM(b: number): number { + this.#computeLm(this.#blockHead[b]!); + let minCon = -1; + let minLm = Number.MAX_VALUE; + for (let k = 1; k < this.#orderLen; k++) { + const c = this.#parentCon[this.#order[k]!]!; + if (this.#conLm[c]! < minLm) { + minLm = this.#conLm[c]!; + minCon = c; + } + } + this.#minLm = minLm; + return minCon; + } + + #findMinLMBetween(lv: number, rv: number): number { + this.#computeLm(lv); + let minCon = -1; + let minLm = Number.MAX_VALUE; + let v = rv; + while (v !== lv) { + const c = this.#parentCon[v]!; + if (this.#conRight[c] === v && this.#conLm[c]! < minLm) { + minLm = this.#conLm[c]!; + minCon = c; + } + v = this.#parentVar[v]!; + } + return minCon; + } + + // --------------------------------------------------------------------------- + // Allocation / growth + // --------------------------------------------------------------------------- + + #ensureNodeCapacity(n: number): void { + if (n > this.#capacity) { + this.#allocateNode(n); + } + } + + #allocateNode(capacity: number): void { + this.#capacity = capacity; + const blockCapacity = capacity + 2; + + this.#desired = new Float64Array(capacity); + this.#offset = new Float64Array(capacity); + this.#varBlock = new Int32Array(capacity); + this.#varNext = new Int32Array(capacity); + + this.#blockPosn = new Float64Array(blockCapacity); + this.#blockSumDesired = new Float64Array(blockCapacity); + this.#blockSumOffset = new Float64Array(blockCapacity); + this.#blockCount = new Int32Array(blockCapacity); + this.#blockHead = new Int32Array(blockCapacity); + this.#blockListIndex = new Int32Array(blockCapacity); + this.#blockAlive = new Uint8Array(blockCapacity); + this.#blocksList = new Int32Array(blockCapacity); + this.#freeBlocks = new Int32Array(blockCapacity); + + this.#outOffsets = new Int32Array(capacity + 1); + this.#inOffsets = new Int32Array(capacity + 1); + this.#csrCursor = new Int32Array(capacity); + + this.#stack = new Int32Array(capacity); + this.#order = new Int32Array(capacity); + this.#parentVar = new Int32Array(capacity); + this.#parentCon = new Int32Array(capacity); + this.#subtree = new Float64Array(capacity); + this.#visited = new Int32Array(capacity); + this.#visitStamp = 0; + + this.#slLeft = new Int32Array(capacity); + this.#slRight = new Int32Array(capacity); + this.#slParent = new Int32Array(capacity); + this.#slPriority = new Uint32Array(capacity); + for (let i = 0; i < capacity; i++) { + this.#slPriority[i] = hashU32(i + 1); + } + + this.#eventOrder = new Int32Array(2 * capacity); + this.#eventPos = new Float64Array(2 * capacity); + } + + #allocateConstraints(conCapacity: number): void { + this.#conCapacity = conCapacity; + this.#conLeft = new Int32Array(conCapacity); + this.#conRight = new Int32Array(conCapacity); + this.#conGap = new Float64Array(conCapacity); + this.#conActive = new Uint8Array(conCapacity); + this.#conLm = new Float64Array(conCapacity); + this.#outCons = new Int32Array(conCapacity); + this.#inCons = new Int32Array(conCapacity); + this.#inactive = new Int32Array(conCapacity); + } + + #growConstraints(needed: number): void { + const newCapacity = Math.max(needed, this.#conCapacity * 2); + const conLeft = new Int32Array(newCapacity); + const conRight = new Int32Array(newCapacity); + const conGap = new Float64Array(newCapacity); + conLeft.set(this.#conLeft); + conRight.set(this.#conRight); + conGap.set(this.#conGap); + this.#conLeft = conLeft; + this.#conRight = conRight; + this.#conGap = conGap; + this.#conActive = new Uint8Array(newCapacity); + this.#conLm = new Float64Array(newCapacity); + this.#outCons = new Int32Array(newCapacity); + this.#inCons = new Int32Array(newCapacity); + this.#inactive = new Int32Array(newCapacity); + this.#conCapacity = newCapacity; + } +} From 14c60386aec7356f2fba39ab6741357f9f5a4f88 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:17:25 +0200 Subject: [PATCH 45/78] feat: code review --- .../shared/graph-visualizer-2/PERFORMANCE.md | 22 + .../entity-graph-visualizer.tsx | 22 +- .../pages/shared/graph-visualizer-2/frames.ts | 2 +- .../graph-visualizer-2/render/clusters.ts | 25 +- .../graph-visualizer-2/render/community.ts | 164 ++++--- .../graph-visualizer-2/render/edge-arrows.ts | 17 +- .../shared/graph-visualizer-2/render/edges.ts | 4 +- .../graph-visualizer-2/render/flat-dots.ts | 9 +- .../render/gpu/bubble-set-sdf-layer.ts | 31 +- .../graph-visualizer-2/render/labels.ts | 7 +- .../shared/graph-visualizer-2/render/scene.ts | 40 +- .../graph-visualizer-2/render/selection.ts | 25 +- .../render/use-graph-worker.ts | 13 +- .../graph-visualizer-2/worker/PERFORMANCE.md | 71 +++ .../worker/core/graph-worker.test.ts | 411 ++++++++++++++++++ .../worker/core/graph-worker.ts | 301 +++++++++---- .../worker/core/layout-reuse.test.ts | 44 ++ .../worker/core/layout-reuse.ts | 34 ++ .../shared/graph-visualizer-2/worker/entry.ts | 16 +- .../worker/layout/_perf.test.ts | 139 ++++++ .../worker/layout/_probe.test.ts | 39 ++ .../worker/layout/overlap-removal.test.ts | 257 +++++++++++ .../worker/layout/overlap-removal.ts | 240 ++++++++-- .../worker/layout/stress-layout.test.ts | 74 ++++ .../worker/layout/stress-layout.ts | 104 ++++- .../worker/layout/stress-vs-fa2.bench.ts | 21 +- .../worker/store/property.ts | 7 +- .../worker/store/type-registry.ts | 7 +- 28 files changed, 1863 insertions(+), 283 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.test.ts diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md index e3dcd68fc53..e61a20c0e37 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/PERFORMANCE.md @@ -63,6 +63,28 @@ structure‑frame handler resolve icons per _type_ (not per _dot_) and only for added dots. Those two dominate main‑thread frame cost while a graph is streaming in or settling. +### Implementation status + +| # | Status | Notes | +| --- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | ⏳ Deferred | Full layer persistence + per‑layout "moved" gating is a large re‑architecture that changes deck binary‑attribute re‑upload semantics; wants runtime profiling + visual QA. The concrete per‑frame _gather/alloc_ hotspots it names are addressed by R3 (community) below. | +| R2 | ✅ Done | `resolveEntityIcon` is memoised by the `entityTypeIds` key in the bridge, so thousands of per‑dot type‑hierarchy walks collapse to one per distinct type‑set. (Incremental "only scan added dots" not done — the memo already removes the dominant cost.) | +| R3 | ✅ Done | Community grouping is cached by the `communities` array identity; the positions texture is uploaded **in place** (`Texture.writeData`) and only re‑created when the kept‑community set / dimensions change. | +| R4 | ⏳ Deferred | Per‑leaf endpoint‑buffer reuse; needs the same deck re‑upload‑semantics care as R1 (risk of edge/dot tearing) and is best validated in‑app. | +| R5 | ⏳ Deferred | Bézier shader sample‑count / underlay‑fold is a visual‑quality trade‑off that must be compared on‑screen before shipping. | +| R6 | ✅ Done | Cluster/edge label rebuild + full layer re‑push is gated on a fine zoom **bucket** (`LABEL_COLOR_ZOOM_BUCKETS_PER_UNIT`) instead of every wheel delta; a pure sub‑bucket zoom now does no layer work. | +| R7 | ✅ Done | `#emitEntityLabels` diffs the projected hub set (ids + rounded x/y + text) and skips the React `setState` when unchanged, so a settled graph stops re‑rendering the bridge subtree. | +| R8 | ✅ Done | `PositionsFrame.settled` now means "no layout (cluster **or** entity/flat) is running", and the worker emits exactly one final `settled: true` frame when the last layout settles. | +| R9 | ⏳ Deferred | Metaball spatial binning is a substantial shader rewrite needing GPU profiling + visual QA. | +| R10 | ⏳ Deferred | Low value; secondary edge‑pick debounce. | +| R11 | ✅ Partial (R11a) | The misleading `edgeArrowLayer` split `WeakMap` (a guaranteed cache miss — the array is fresh each frame) is removed. The `characterSet:"auto"` and empty‑overlay points fold into the deferred R1/R6 persistence work. | + +The deferred items are either visual‑quality trade‑offs (R5, R9) or layer‑persistence +re‑architectures (R1, R4) whose deck re‑upload semantics should be validated against a +live profile/frame‑capture rather than changed blind. The applied set targets both +**High** items (R2, R3) and the coordination/render‑loop **Medium**s (R6, R7, R8) with +behaviour‑preserving changes. + --- ## 2. Render loop diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx index 5350b59c5b0..d5fd175e5d0 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/entity-graph-visualizer.tsx @@ -408,24 +408,40 @@ export const EntityGraphVisualizerV2 = memo( // image URL); a ReactElement icon (system-type override) or none -> null -> no atlas entry, // so that dot simply shows no icon. NOT gated on hubs: the IconLayer's soft-LOD sizing hides // icons on dots that are small on screen, so every entity is eligible. + // Icon resolution walks the type hierarchy; memo by type-set key so + // each distinct set resolves once. Cache resets when the root map changes. + const iconByTypeKey = useMemo(() => { + void closedMultiEntityTypesRootMap; + return new Map(); + }, [closedMultiEntityTypesRootMap]); const resolveEntityIcon = useCallback( (entityId: EntityId): string | null => { const entity = entityById.get(entityId); if (!entity || !closedMultiEntityTypesRootMap) { return null; } + const typeKey = [...entity.metadata.entityTypeIds] + .sort() + .join("\u0000"); + const cached = iconByTypeKey.get(typeKey); + if (cached !== undefined) { + return cached; + } + let resolved: string | null; try { const closedType = getClosedMultiEntityTypeFromMap( closedMultiEntityTypesRootMap, entity.metadata.entityTypeIds, ); const { icon } = getDisplayFieldsForClosedEntityType(closedType); - return typeof icon === "string" && icon.length > 0 ? icon : null; + resolved = typeof icon === "string" && icon.length > 0 ? icon : null; } catch { - return null; + resolved = null; } + iconByTypeKey.set(typeKey, resolved); + return resolved; }, - [entityById, closedMultiEntityTypesRootMap], + [entityById, closedMultiEntityTypesRootMap, iconByTypeKey], ); // Reset when the worker is torn down (ready goes false→true on first build OR a sourceKey diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts index fcb82f93008..8b9a217e3c6 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/frames.ts @@ -262,7 +262,7 @@ export interface RenderEdgeArrow { */ export interface PositionsFrame { readonly version: number; - /** True once every macro layout has settled; the last frame of a sequence. */ + /** True once every layout (cluster and entity/flat) has settled. */ readonly settled: boolean; /** World positions, index-aligned with {@link StructureFrame.clusters}. */ readonly clusterPositions: Float32Array; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts index 66991cad742..dad0e494090 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/clusters.ts @@ -1,10 +1,6 @@ /** - * The hierarchical-LOD render, split by update rate: - * - cluster bubbles: a persistent `placed` array (rebuilt on structure, positions mutated - * in place each settling frame) drawn by `clusterBubbleLayer` with `updateTriggers`, so a - * tick re-uploads only positions, never radius/colour; - * - per open leaf, its entity-incident edges (straight lines) and entity dots, read - * straight from the leaf SAB so lines and dots never tear. + * Hierarchical-LOD render: cluster bubbles (positions mutated in place, + * radius/colour stable) and per-leaf entity dots + edges from the leaf SAB. */ import { LineLayer, ScatterplotLayer } from "@deck.gl/layers"; @@ -47,8 +43,7 @@ function containerFillColor( return [red, green, blue, out]; } -/** Build the bubble set from a structure frame, ordered deepest-container-first so leaf - * bubbles land on top. */ +/** Build placed bubbles from a structure frame, deepest-container-first. */ export function buildPlaced( structure: StructureFrame, positions: PositionsFrame, @@ -64,8 +59,7 @@ export function buildPlaced( return placed; } -/** Mutate the placed bubbles' positions in place (array identity preserved, so the bubble - * layer's updateTrigger is what re-uploads them, leaving radius/colour untouched). */ +/** Mutate placed positions in place (array identity preserved for updateTrigger). */ export function updatePlaced( placed: PlacedCluster[], positions: PositionsFrame, @@ -77,14 +71,12 @@ export function updatePlaced( } } -/** Cluster bubbles. `positionTick` drives the getPosition updateTrigger, so a settling - * frame re-uploads only positions; radius regenerates only when `placed` is rebuilt (the - * structure changed), and colour also when `highlightTick` changes (the focus dim). */ +/** Cluster bubble layer. Position re-uploads on tick; radius on structure change; + * colour on highlight change. */ export function clusterBubbleLayer( placed: PlacedCluster[], positionTick: number, - /** While a highlight is active, the clusters to keep at full colour (the rest recede); null - * when nothing is selected. `highlightTick` drives the getFillColor updateTrigger. */ + /** Clusters to keep at full colour during a highlight; null when no selection. */ keepFull: ReadonlySet | null, highlightTick: number, ): Layer { @@ -119,8 +111,7 @@ export function clusterBubbleLayer( }); } -/** Per open leaf: its entity-incident edges (straight) and entity dots, drawn ON TOP of - * the faint container bubble. */ +/** Per open leaf: entity-incident edges and entity dots. */ export function clusterEntityLayers(config: { readonly structure: StructureFrame; readonly positions: PositionsFrame; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts index 59769bff257..c5803e8caad 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/community.ts @@ -4,19 +4,13 @@ import { FLAT_RECORD_BYTES, } from "../worker/buffers/position-buffer"; /** - * community-force "BubbleSets": one crisp metaball isocontour per Louvain community, - * drawn behind the dots/edges. Pure layer builder — gathers each kept community's node - * centres (from the same SAB the dots read) into the {@link BubbleSetSDFLayer}'s positions - * texture; the shader sums + thresholds the field. + * Community-force metaball isocontours: one per Louvain community, drawn + * behind dots/edges. Gathers each kept community's node centres from the + * SAB into a positions texture; the SDF shader sums and thresholds the field. * - * PERF TODO (only if this shows up in a profile): this re-gathers the grouped positions - * texture and recomputes the bbox every frame (O(nodes)). The win is a STABLE per-community - * index list ([offset, count] into an index buffer of SAB node indices), rebuilt only when - * communities change (Louvain rerun), with the SDF shader reading SAB positions directly via - * that index (stride-aware, since the SAB is interleaved); keep the bbox CPU from the index - * lists (cheap O(nodes) min/max) or move it to a GPU reduction. NOTE: the naive "per-node - * membership + -1, scan all nodes per pixel" is a REGRESSION (O(N)/pixel) — the index list is - * the win. Last resort: move the grouping + bbox into the worker and ride the frame. + * The per-community index list is cached by `communities` array identity + * ({@link groupingCache}); a settling frame skips the regroup and only + * re-gathers moved node centres plus recomputes bounding boxes. */ import { BubbleSetSDFLayer } from "./gpu/bubble-set-sdf-layer"; @@ -36,32 +30,34 @@ const MIN_COMMUNITY_SIZE = 4; const BUBBLE_TEX_WIDTH = 256; /** - * Community "BubbleSets" for community-force: ONE crisp metaball isocontour per - * Louvain community, coloured by community, drawn BEHIND the dots/edges. Builds - * the per-community instances (bbox + colour + node range) and a positions texture - * of the kept communities' node centres (gathered from the SAME SAB as the dots); - * the layer's shader sums + thresholds the field. Only non-trivial communities are - * promoted ({@link MIN_COMMUNITY_SIZE}). Absent in flat-force (no `communities`). + * Stable grouping for one Louvain result. Changes only when Louvain reruns; + * cached by array identity and reused across position frames while settling. */ -export function communityLayer( - graph: RenderFlatGraph, - clusters: Map, -): Layer[] { - const membership = graph.communities; - if (!membership) { - return []; - } - const cluster = clusters.get(graph.layoutId); - if (!cluster) { - return []; - } - const floats = new Float32Array(cluster.versionView.buffer); - const headerFloats = FLAT_HEADER_BYTES / 4; - const recordFloats = FLAT_RECORD_BYTES / 4; +interface CommunityGrouping { + /** Kept communities' node SAB indices, laid out community-by-community in gather order. */ + readonly memberIndices: Int32Array; + /** Per kept community: `[offset, count]` into {@link memberIndices} / the positions texture. */ + readonly ranges: Float32Array; + /** Per kept community RGBA (community id → colour). Constant for the grouping's life. */ + readonly colors: Uint8Array; + readonly keptCount: number; + readonly texWidth: number; + readonly texHeight: number; + /** Node-centre texture data, refilled from the SAB each frame. */ + readonly positions: Float32Array; + /** Bumped on each refill to trigger an in-place texture re-upload. */ + version: number; +} - // Group node indices by community; keep only the non-trivial ones. +const groupingCache = new WeakMap(); + +/** Build the stable grouping for a Louvain membership array, or null if no community is big enough. */ +function buildGrouping( + membership: Int32Array, + count: number, +): CommunityGrouping | null { const byCommunity = new Map(); - for (let idx = 0; idx < graph.count; idx++) { + for (let idx = 0; idx < count; idx++) { const community = membership[idx] ?? -1; if (community < 0) { continue; @@ -77,56 +73,109 @@ export function communityLayer( ([, members]) => members.length >= MIN_COMMUNITY_SIZE, ); if (kept.length === 0) { - return []; + return null; } - // Per-community node centres → one grouped positions texture; per-community - // instances (bbox padded by the field radius so the kernel falloff fits, colour, - // and the [offset, count] range into the texture). const totalNodes = kept.reduce((sum, [, members]) => sum + members.length, 0); const texHeight = Math.max(1, Math.ceil(totalNodes / BUBBLE_TEX_WIDTH)); - const positions = new Float32Array(BUBBLE_TEX_WIDTH * texHeight * 2); - const bounds = new Float32Array(kept.length * 4); - const colors = new Uint8Array(kept.length * 4); + const memberIndices = new Int32Array(totalNodes); const ranges = new Float32Array(kept.length * 2); + const colors = new Uint8Array(kept.length * 4); let offset = 0; for (let ci = 0; ci < kept.length; ci++) { const [community, members] = kept[ci]!; - const start = offset; + ranges[ci * 2] = offset; + ranges[ci * 2 + 1] = members.length; + const [red, green, blue, alpha] = communityColorForId(community); + colors[ci * 4] = red; + colors[ci * 4 + 1] = green; + colors[ci * 4 + 2] = blue; + colors[ci * 4 + 3] = alpha; + for (const idx of members) { + memberIndices[offset] = idx; + offset += 1; + } + } + + return { + memberIndices, + ranges, + colors, + keptCount: kept.length, + texWidth: BUBBLE_TEX_WIDTH, + texHeight, + positions: new Float32Array(BUBBLE_TEX_WIDTH * texHeight * 2), + version: 0, + }; +} + +/** + * Build the community bubble-set layer for the current frame. The grouping + * topology is cached; only node centres and bounding boxes are refreshed. + */ +export function communityLayer( + graph: RenderFlatGraph, + clusters: Map, +): Layer[] { + const membership = graph.communities; + if (!membership) { + return []; + } + const cluster = clusters.get(graph.layoutId); + if (!cluster) { + return []; + } + + let grouping = groupingCache.get(membership); + if (grouping === undefined) { + const built = buildGrouping(membership, graph.count); + if (built === null) { + return []; + } + grouping = built; + groupingCache.set(membership, grouping); + } + + const floats = new Float32Array(cluster.versionView.buffer); + const headerFloats = FLAT_HEADER_BYTES / 4; + const recordFloats = FLAT_RECORD_BYTES / 4; + const { memberIndices, ranges, colors, keptCount, positions } = grouping; + + // Re-gather node centres + recompute bounding boxes. `bounds` is re-allocated + // (Deck re-uploads it); `positions` is refilled in place (version-driven upload). + const bounds = new Float32Array(keptCount * 4); + for (let ci = 0; ci < keptCount; ci++) { + const start = ranges[ci * 2]!; + const memberCount = ranges[ci * 2 + 1]!; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; - for (const idx of members) { + for (let member = 0; member < memberCount; member++) { + const slot = start + member; + const idx = memberIndices[slot]!; const posX = floats[headerFloats + idx * recordFloats] ?? 0; const posY = floats[headerFloats + idx * recordFloats + 1] ?? 0; - positions[offset * 2] = posX; - positions[offset * 2 + 1] = posY; + positions[slot * 2] = posX; + positions[slot * 2 + 1] = posY; minX = Math.min(minX, posX); maxX = Math.max(maxX, posX); minY = Math.min(minY, posY); maxY = Math.max(maxY, posY); - offset += 1; } bounds[ci * 4] = minX - FLAT_BUBBLE_FIELD_RADIUS; bounds[ci * 4 + 1] = minY - FLAT_BUBBLE_FIELD_RADIUS; bounds[ci * 4 + 2] = maxX + FLAT_BUBBLE_FIELD_RADIUS; bounds[ci * 4 + 3] = maxY + FLAT_BUBBLE_FIELD_RADIUS; - const [red, green, blue, alpha] = communityColorForId(community); - colors[ci * 4] = red; - colors[ci * 4 + 1] = green; - colors[ci * 4 + 2] = blue; - colors[ci * 4 + 3] = alpha; - ranges[ci * 2] = start; - ranges[ci * 2 + 1] = members.length; } + grouping.version += 1; return [ new BubbleSetSDFLayer({ id: "flat-bubbles", data: { - length: kept.length, + length: keptCount, attributes: { getBounds: { value: bounds, size: 4 }, getColor: { value: colors, size: 4 }, @@ -134,8 +183,9 @@ export function communityLayer( }, }, positions, - texWidth: BUBBLE_TEX_WIDTH, - texHeight, + positionsVersion: grouping.version, + texWidth: grouping.texWidth, + texHeight: grouping.texHeight, fieldRadius: FLAT_BUBBLE_FIELD_RADIUS, isoThreshold: 0.58, // A backdrop must not write depth, or its bounding quad stamps the depth buffer and the diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts index 7dfb045f3a8..76dc2e26aa5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edge-arrows.ts @@ -14,11 +14,6 @@ interface EdgeArrowSplit { readonly endpoints: RenderEdgeArrow[]; } -const edgeArrowSplitCache = new WeakMap< - readonly RenderEdgeArrow[], - EdgeArrowSplit ->(); - function fadeAlpha(screenMetric: number, threshold: number, alpha: number) { const progress = Math.min( 1, @@ -37,11 +32,8 @@ function arrowAngleDegrees(arrow: RenderEdgeArrow): number { } function splitEdgeArrows(arrows: readonly RenderEdgeArrow[]): EdgeArrowSplit { - const cached = edgeArrowSplitCache.get(arrows); - if (cached) { - return cached; - } - + // `positions.edgeArrows` is a fresh array every frame (the worker transfers it), so there is + // nothing stable to cache by; the partition is a couple of cheap pushes over a bounded list. const lanes: RenderEdgeArrow[] = []; const endpoints: RenderEdgeArrow[] = []; for (const arrow of arrows) { @@ -51,10 +43,7 @@ function splitEdgeArrows(arrows: readonly RenderEdgeArrow[]): EdgeArrowSplit { endpoints.push(arrow); } } - - const split = { lanes, endpoints }; - edgeArrowSplitCache.set(arrows, split); - return split; + return { lanes, endpoints }; } export function edgeArrowLayer( diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts index 53bec0df29d..567080469c5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/edges.ts @@ -1,6 +1,6 @@ /** - * Hierarchical edges render as GPU SDF beziers. Flat edges reuse the same packed segment buffer, - * but render as plain LineLayer segments because their control points are collinear. + * Edge rendering: hierarchical edges as GPU SDF beziers, flat edges as + * LineLayer segments (collinear control points). */ import { LineLayer } from "@deck.gl/layers"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts index 4a256008dde..3fa00ead0a9 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/flat-dots.ts @@ -1,9 +1,6 @@ /** - * The flat-tier (flat-force / community-force) NODE render: every entity as one dot, read - * STRAIGHT off the interleaved SAB (positions + radii + colours in one buffer). Fresh - * typed-array views over the shared bytes each render (zero-copy, new identity so Deck - * re-uploads) with stride/offset onto the record fields; positions are world coords - * centred on the origin, so there is no transform. Edges are drawn by the bezier layer. + * Flat-tier entity dots: each node as a scatterplot dot, read directly from + * the interleaved SAB via stride/offset binary attributes (zero-copy). */ import { ScatterplotLayer } from "@deck.gl/layers"; @@ -27,7 +24,7 @@ export function flatDotsLayer( if (!cluster) { return []; } - // Views over the WHOLE buffer; the stride/offset address each record field. + // Views over the whole buffer; stride/offset address each record field. const raw = cluster.versionView.buffer; const floats = new Float32Array(raw); const bytes = new Uint8Array(raw); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts index 70e162cb6c7..48f089314dd 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/bubble-set-sdf-layer.ts @@ -155,6 +155,8 @@ interface BubbleSetSDFLayerProps extends LayerProps { readonly data: BinaryData; /** Node centres (world), grouped by community: `texWidth * texHeight` rg32f texels. */ readonly positions: Float32Array; + /** Bumped when positions are refilled in place; drives a texture re-upload without reallocation. */ + readonly positionsVersion?: number; readonly texWidth: number; readonly texHeight: number; /** Metaball field radius per node, in `common` (world) units. */ @@ -164,6 +166,7 @@ interface BubbleSetSDFLayerProps extends LayerProps { const defaultProps = { positions: { type: "object" as const, value: new Float32Array(0) }, + positionsVersion: { type: "number" as const, value: 0 }, texWidth: { type: "number" as const, value: 256 }, texHeight: { type: "number" as const, value: 1 }, fieldRadius: { type: "number" as const, value: 55 }, @@ -219,12 +222,19 @@ export class BubbleSetSDFLayer extends Layer { this.getAttributeManager()?.invalidateAll(); } - if ( - props.positions !== oldProps.positions || + const state = this.state as BubbleLayerState; + const dimensionsChanged = props.texWidth !== oldProps.texWidth || - props.texHeight !== oldProps.texHeight + props.texHeight !== oldProps.texHeight; + if (state.texture === undefined || dimensionsChanged) { + // First build or dimensions changed: (re)allocate the texture. + this._createTexture(); + } else if ( + props.positions !== oldProps.positions || + props.positionsVersion !== oldProps.positionsVersion ) { - this._updateTexture(); + // Same dimensions, moved centres: re-upload in place. + this._uploadTexture(); } } @@ -252,8 +262,8 @@ export class BubbleSetSDFLayer extends Layer { super.finalizeState(context); } - /** (Re)upload the per-community-grouped node positions as an rg32float texture. */ - _updateTexture() { + /** Allocate the rg32float positions texture and upload the current node centres. */ + _createTexture() { const state = this.state as BubbleLayerState; state.texture?.destroy(); state.texture = this.context.device.createTexture({ @@ -265,6 +275,15 @@ export class BubbleSetSDFLayer extends Layer { }); } + /** Re-upload moved node centres into the existing texture. */ + _uploadTexture() { + const state = this.state as BubbleLayerState; + state.texture?.writeData(this.props.positions, { + width: this.props.texWidth, + height: this.props.texHeight, + }); + } + _getModel(): Model { const positions = new Float32Array([ -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts index 89ef6ad58ad..804f8cb4891 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/labels.ts @@ -1,9 +1,8 @@ /** - * Text labels, faded by on-screen size so they don't pop in/out at low zoom: cluster labels (by - * bubble radius) and highway connection-count labels (by edge chord length) for the hierarchical - * tier. `zoom` is the current view zoom (`2 ** zoom` = world->pixel scale). + * Cluster and highway edge labels, faded by on-screen size so they don't + * pop in/out at low zoom. * - * PERF TODO: Use the collision extension instead to automtically cull overlapping labels. + * PERF TODO: Use the collision extension to automatically cull overlapping labels. */ import { TextLayer } from "@deck.gl/layers"; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts index f9fbef711a3..bde02214e6a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts @@ -109,6 +109,8 @@ const ENTITY_LABEL_COLLISION_PADDING_PX = 4; const LABEL_ZOOM_BUCKETS_PER_UNIT = 4; /** Re-evaluate icon visibility/color only on coarse zoom buckets, not every wheel delta. */ const ICON_ZOOM_BUCKETS_PER_UNIT = 8; +/** Cluster/edge label layers rebuild only when zoom crosses this bucket. */ +const LABEL_COLOR_ZOOM_BUCKETS_PER_UNIT = 24; /** Worker-side edge/LOD geometry does not need every tiny wheel delta. */ const WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT = 16; const WORKER_VIEWPORT_MAX_FPS = 20; @@ -136,6 +138,10 @@ function iconZoomBucket(zoom: number): number { return Math.floor(zoom * ICON_ZOOM_BUCKETS_PER_UNIT); } +function labelColorZoomBucket(zoom: number): number { + return Math.round(zoom * LABEL_COLOR_ZOOM_BUCKETS_PER_UNIT); +} + function workerViewportZoom(zoom: number): number { return ( Math.round(zoom * WORKER_VIEWPORT_ZOOM_BUCKETS_PER_UNIT) / @@ -330,6 +336,10 @@ export class Scene { #labelLayers: Layer[] = []; #labelZoomBucket = labelZoomBucket(viewStateZoom(INITIAL_VIEW_STATE)); #iconZoomBucket = iconZoomBucket(viewStateZoom(INITIAL_VIEW_STATE)); + #labelColorZoomBucket = labelColorZoomBucket( + viewStateZoom(INITIAL_VIEW_STATE), + ); + /** * The always-on entity-label SET + resolved text. Rebuilt ONLY on a zoom or structure change * (the perf rule -- never an O(n) scan or a label resolve on a pan / position frame); the @@ -402,6 +412,8 @@ export class Scene { #hasLastViewport = false; #lastViewportSentAt = 0; #entityLabelsFrame: number | null = null; + /** Signature of the last hub-label set emitted to React; skips setState when unchanged. */ + #lastEmittedLabelSignature = ""; #isDragging = false; /** Persistent cluster-bubble set: rebuilt on structure, positions mutated in place. */ #placed: PlacedCluster[] = []; @@ -698,23 +710,23 @@ export class Scene { #applyViewState(viewState: ViewState): void { const nextZoom = viewStateZoom(viewState); - const zoomChanged = nextZoom !== viewStateZoom(this.#viewState); const nextLabelZoomBucket = labelZoomBucket(nextZoom); const labelEligibilityChanged = nextLabelZoomBucket !== this.#labelZoomBucket; const nextIconZoomBucket = iconZoomBucket(nextZoom); const iconEligibilityChanged = nextIconZoomBucket !== this.#iconZoomBucket; + const nextLabelColorZoomBucket = labelColorZoomBucket(nextZoom); + const labelColorBucketChanged = + nextLabelColorZoomBucket !== this.#labelColorZoomBucket; this.#viewState = viewState; this.#labelZoomBucket = nextLabelZoomBucket; this.#iconZoomBucket = nextIconZoomBucket; + this.#labelColorZoomBucket = nextLabelColorZoomBucket; this.#deck.setProps({ viewState }); this.#scheduleViewport(); - // Cluster/edge labels fade by zoom alpha, so their cheap layer props update on zoom. Entity - // label eligibility is the expensive O(dots) path and only changes on coarse zoom buckets. - // GPU layer reconciliation is the expensive path. Only ZOOM needs it (screen-size LOD changes - // which dots label + the label/edge layers); a pure pan reprojects the canvas via viewState, so - // skip the rebuild there. - if (zoomChanged) { + // Label layers rebuild on zoom bucket changes; a pure pan reprojects + // via viewState with no rebuild. + if (labelColorBucketChanged) { this.#rebuildLabels(); } if (labelEligibilityChanged) { @@ -722,7 +734,7 @@ export class Scene { } if (iconEligibilityChanged) { this.#refreshDataLayers(); - } else if (zoomChanged) { + } else if (labelColorBucketChanged) { this.#pushLayers(); } // HTML overlays (selection / highway / cluster cards + hub labels) are positioned by PROJECTED @@ -1555,6 +1567,18 @@ export class Scene { occupied.push(rect); labels.push({ entityId: datum.entityId, text: datum.text, x: labelX, y }); } + // Skip the React setState when the projected label set is unchanged + // (positions rounded to whole pixels so sub-pixel drift is invisible). + const signature = labels + .map( + (label) => + `${label.entityId}|${Math.round(label.x)}|${Math.round(label.y)}|${label.text}`, + ) + .join(";"); + if (signature === this.#lastEmittedLabelSignature) { + return; + } + this.#lastEmittedLabelSignature = signature; onLabels(labels); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts index c44c52771b1..efd42f1f53a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/selection.ts @@ -1,9 +1,6 @@ /** - * The selected entity dot: a ring drawn over it, tracked by the buffer + render index the - * pick resolved to so its position is read LIVE from the same SAB the dots use. The ring - * therefore rides a settling layout (via the position tick) and pan/zoom (it is world-space) - * with no rebuild round-trip. The Scene owns the selection state + gestures; this module - * owns the geometry read and the layer. + * Selection ring over a picked entity dot. Position is read live from the + * SAB so the ring rides a settling layout with no rebuild round-trip. */ import { ScatterplotLayer } from "@deck.gl/layers"; @@ -43,9 +40,9 @@ export interface SelectionGeometry { } /** - * The selected node's current world position + radius, or null if its layout/record is gone. - * The flat buffer is interleaved world-space records; a hierarchical leaf is positions-only - * and LOCAL to its leaf origin (added back here from the cluster positions frame). + * World position + radius of the selected node, or null if its layout is gone. + * Flat buffers store world-space records directly; hierarchical leaves are local + * to their leaf origin (offset added here from the cluster positions frame). */ export function nodeGeometry( layoutId: ClusterId, @@ -81,11 +78,7 @@ export function nodeGeometry( }; } -/** - * A screen-space ring over each selected geometry. The anchor is in graph world space, but the - * mark itself is pixel-sized UI so selection remains readable at every zoom without min/max clamps. - * Never pickable -- it must not eat the dot it rings. - */ +/** Screen-space ring at a world-space anchor. Not pickable (must not eat the dot it rings). */ function ringLayer( id: string, data: readonly SelectionGeometry[], @@ -114,11 +107,7 @@ function ringLayer( }); } -/** - * A neutral ring on the selected node. World-space + `positionTick`-triggered so it tracks - * settling + pan/zoom; one layer, empty data when nothing is selected so the layer set stays - * stable. Ego neighbors are conveyed by the focus dim (un-dimmed dots + bubbles), not rings. - */ +/** Selection ring layers. Empty data when nothing is selected (stable layer set). */ export function selectionOverlayLayers( selected: SelectionGeometry | null, positionTick: number, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts index e43a7ac704f..67fcce71d02 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/use-graph-worker.ts @@ -1,9 +1,4 @@ -/** - * Thin React lifecycle wrapper around {@link WorkerConnection}: creates the - * connection on mount, surfaces the only two pieces of state the tree re-renders on - * (`ready` for the ingest gate, `error`), and feeds it new type schemas. All per-frame - * data flows through the connection's subscribe stream, never React state. - */ +/** React lifecycle wrapper around {@link WorkerConnection}. */ import { useEffect, useState } from "react"; import { defaultVizConfig } from "../config"; @@ -17,11 +12,7 @@ interface UseGraphWorkerOptions { readonly config?: VizConfig; readonly typeSchemas: readonly TypeSchemaEntry[]; readonly propertySchemas: readonly PropertySchemaEntry[]; - /** - * Tears down and recreates the worker whenever this value changes. The worker's ingest is - * additive, so it has no way to retract entities; the caller changes this when the entity set is - * REPLACED (its data source changed), not merely extended, to start from a clean slate. - */ + /** Changing this tears down and recreates the worker (clean-slate reset). */ readonly resetKey?: string | number; } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md index b441d3fe813..e51e15b34cc 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/PERFORMANCE.md @@ -32,6 +32,77 @@ Headline findings: --- +## Status (updated) + +`commitStructure` is now **change‑aware** rather than unconditionally rebuilding +every commit. Implemented in `core/graph-worker.ts`, guarded by +`core/graph-worker.test.ts`: + +- **F1 — fixed.** The flat/community commit maintains the sorted node‑index list + incrementally (no per‑commit O(N log N) re‑sort), detects topology change from + monotonic node/link counts in O(1) (no dual‑`Set` scan), returns early on a + true no‑op, and separates a cheap restyle‑only path (type change / root flip) + from a full topology rebuild. **No‑op re‑commit: ~8.45 ms → ~25 ns** at 1,500 + nodes (`commit-rebuild.bench.ts`). +- **F10 — fixed.** `handleViewport` hands its already‑computed cut to + `commitStructure`, which reuses it instead of walking the tree a second time + (unless the commit itself mutated the tree). +- **F13 — fixed.** The distinctive‑feature naming job re‑emits the current + topology with fresh labels (`#recommitLabelsOnly`) instead of forcing a second + full cut + aggregation rebuild. +- **Hierarchical no‑op — fixed.** The hierarchical commit now classifies a no‑op + with a monotonic `#clusterEpoch` (bumped by every tree mutation: rebuild, + incremental update, lazy subdivision, embedding result) plus the link count and + an unchanged cut open‑state; when all match (and no root flip is pending) it + skips `CutIndex`, edge aggregation, entity layers, and both frame emits. A bare + no‑op re‑commit at 8,000 nodes is **~6.5 µs** (`commit-rebuild.bench.ts`), down + from ~3.2 ms. It doesn't reach the flat tier's ~26 ns because the guard sits + _after_ `computeVisibleCut`, so the cut is still recomputed — but that walk only + covers the _open_ part of the tree, so at a typical viewport it is itself only a + few µs. A pre‑cut short‑circuit (skip `computeVisibleCut` when epoch, link count, + viewport, pinned set, and cluster‑layout positions are all unchanged since the + last emit) could shave the residual, but it is a minor win. Making `CutIndex` + incremental for _real_ cut changes (F9) is still open. +- **`pin()` guard — fixed.** Pinning/unpinning now mirrors `handleViewport`: + it recomputes the candidate cut with the new pin set and commits only when the + open‑state actually changes, so pinning a leaf the current zoom already opens is + no longer a full hierarchical rebuild. +- **`REGISTER_TYPES` classification + ingest‑driven top‑level re‑layout — fixed.** + `registerTypes` reports whether a genuinely new type (or property title) was + added, and `entry.ts` commits `rebuildTree: true` only when a new type arrived; + an identical re‑registration — the common case — commits nothing. A first, + gating‑only attempt regressed the top level: the expansion flow + (`entity-graph-visualizer.tsx`) sends `registerTypes` then `ingestBatch`, and the + old code re‑arranged the top‑level clusters purely as a _side effect_ of the + registration's `rebuildTree` (which clears `#forceLayouts`). With the commit + gated off for known types, expanding into them left the overview frozen (proven + in `graph-worker.test.ts`). The fix makes top‑level re‑layout a first‑class + property of _ingest_: the root macro layout re‑warms when a top‑level cluster + grows past `GROWTH_RELAYOUT_TOLERANCE_FRAC` (`layoutOutgrown` / + `#clusterLayoutOutgrown`), complementing the overlap‑driven rebuild + (`layoutNeedsRebuild`). A growing hierarchy now visibly re‑arranges as data + streams in, while a trickle of new members and redundant type re‑registrations + both stay stable (no churn). The threshold is deliberately separate from the + overlap guard, which treats growth‑with‑slack as churn to avoid. +- **Duplicate leaf maps — fixed.** The per‑leaf entity‑index → local‑slot map is + memoised on the layout object (`#leafLocalOf`), so `#buildEntityLayers` + (structure) and `#buildEntityFanOut` (positions) build it once per leaf per + topology and reuse it across position ticks, instead of rebuilding it twice per + emit. + +Still open (call‑frequency, not `commitStructure` internals): **F2** — `entry.ts` +still issues one commit per `INGEST_BATCH`, so a streamed graph is ~24× a bulk +load. Coalescing a streaming burst into a single commit (accumulating + merging +deltas, deferred via a MessageChannel behind the queued batches) is the +recommended next step. **F9** (incremental `CutIndex` for real cut changes) and +the remaining medium items below are also unaddressed. The broader versioned +invalidation the review sketches (per‑artifact dirty bits for highway lanes, +ports, rendered‑cluster metadata, Bézier geometry) is a larger follow‑up; the +guards above cover the true no‑op and the specific redundant recomputations +called out, not per‑artifact caching on partial changes. + +--- + ## 1. Methodology ### Fixtures diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.test.ts new file mode 100644 index 00000000000..b086f31b52a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.test.ts @@ -0,0 +1,411 @@ +/** + * Guards for the change-aware {@link GraphWorker.commitStructure} flat-tier + * path: a commit with nothing new must do no structural work (no rebuild, no + * re-emit), a colour-only change must restyle without rebuilding the layout, + * and streaming a graph in batches must land in the same committed state as + * ingesting it in one shot. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { defaultVizConfig } from "../../config"; +import { ClusterId } from "../../ids"; +import { + benchEntityId, + benchNodeTypeUrl, + benchTypeSchemas, + buildIngestEntities, +} from "../bench-fixtures"; +import { GraphWorker } from "./graph-worker"; + +import type { PositionsFrame, StructureFrame } from "../../frames"; +import type { GraphShape } from "../bench-fixtures"; +import type { IngestEntity } from "../protocol"; + +/** flat-force stays under `flatLayoutExitNodes` (250); community-force is above it. */ +const FLAT_FORCE: GraphShape = { + nodeCount: 80, + linkCount: 160, + typeCount: 8, + hubCount: 8, + rootFraction: 1, + seed: 7, +}; + +const COMMUNITY_FORCE: GraphShape = { + nodeCount: 600, + linkCount: 1_500, + typeCount: 16, + hubCount: 30, + rootFraction: 1, + seed: 9, +}; + +interface Harness { + readonly worker: GraphWorker; + readonly structure: ReturnType; + readonly positions: ReturnType; + readonly layout: ReturnType; + /** Count of LAYOUT_CREATED / LAYOUT_DESTROYED messages (i.e. layout rebuilds). */ + layoutLifecycleCalls(): number; + /** The `count` from the most recent flat structure frame. */ + lastFlatCount(): number | undefined; +} + +function newHarness(): Harness { + const worker = new GraphWorker(defaultVizConfig); + const structure = vi.fn(); + const positions = vi.fn(); + const layout = vi.fn(); + worker.onStructureFrame = structure; + worker.onPositionsFrame = positions; + worker.onLayoutMessage = layout; + return { + worker, + structure, + positions, + layout, + layoutLifecycleCalls() { + return layout.mock.calls.filter(([msg]) => { + const { type } = msg as { readonly type: string }; + return type === "LAYOUT_CREATED" || type === "LAYOUT_DESTROYED"; + }).length; + }, + lastFlatCount() { + const calls = structure.mock.calls; + const last = calls[calls.length - 1]?.[0] as + | { flatGraph?: { count: number } } + | undefined; + return last?.flatGraph?.count; + }, + }; +} + +/** Register types, ingest the whole shape, and run the first (building) commit. */ +function prime(shape: GraphShape): Harness { + const harness = newHarness(); + harness.worker.registerTypes(benchTypeSchemas(shape.typeCount), []); + const deltas = harness.worker.ingestBatch(buildIngestEntities(shape)); + harness.worker.commitStructure({ deltas }); + return harness; +} + +interface TopLevelCluster { + readonly x: number; + readonly y: number; + readonly radius: number; + readonly count: number; +} + +/** Top-level cluster id -> world position/size, from the latest emitted frames. */ +function topLevelSnapshot(harness: Harness): Map { + const structureCalls = harness.structure.mock.calls; + const positionsCalls = harness.positions.mock.calls; + const structure = structureCalls[structureCalls.length - 1]?.[0] as + | StructureFrame + | undefined; + const positions = positionsCalls[positionsCalls.length - 1]?.[0] as + | PositionsFrame + | undefined; + const snapshot = new Map(); + if (!structure || !positions) { + return snapshot; + } + for (const [index, cluster] of structure.clusters.entries()) { + snapshot.set(cluster.id, { + x: positions.clusterPositions[index * 2] ?? 0, + y: positions.clusterPositions[index * 2 + 1] ?? 0, + radius: cluster.radius, + count: cluster.count, + }); + } + return snapshot; +} + +/** Total absolute position movement of clusters present in both snapshots. */ +function positionDrift( + before: Map, + after: Map, +): number { + let total = 0; + for (const [id, next] of after) { + const prev = before.get(id); + if (prev) { + total += Math.abs(next.x - prev.x) + Math.abs(next.y - prev.y); + } + } + return total; +} + +describe("GraphWorker.commitStructure — flat tier", () => { + // The trailing-Louvain linger uses setTimeout; fake only timers so it can't + // fire mid-test (MessageChannel scheduling and performance.now stay real). + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + for (const shape of [FLAT_FORCE, COMMUNITY_FORCE]) { + const tier = shape === FLAT_FORCE ? "flat-force" : "community-force"; + + it(`does no work on a redundant commit (${tier})`, () => { + const harness = prime(shape); + // The build commit emitted a structure + positions frame and created the + // flat layout; clear the spies and re-commit with nothing changed. + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.layout.mockClear(); + + harness.worker.commitStructure(); + + expect(harness.structure).not.toHaveBeenCalled(); + expect(harness.positions).not.toHaveBeenCalled(); + expect(harness.layout).not.toHaveBeenCalled(); + }); + + it(`restyles without rebuilding on a type change (${tier})`, () => { + const harness = prime(shape); + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.layout.mockClear(); + + // A type (re)registration recolours nodes without adding any; the flat + // path must re-emit style but must not tear down / recreate the layout. + harness.worker.commitStructure({ rebuildTree: true }); + + expect(harness.structure).toHaveBeenCalledTimes(1); + expect(harness.positions).toHaveBeenCalledTimes(1); + expect(harness.layoutLifecycleCalls()).toBe(0); + }); + } + + it("re-emits when new nodes arrive", () => { + const harness = newHarness(); + const entities = buildIngestEntities(FLAT_FORCE); + // Nodes are the leading `nodeCount` entries; split them across two batches. + const half = FLAT_FORCE.nodeCount / 2; + + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(entities.slice(0, half)), + }); + expect(harness.lastFlatCount()).toBe(half); + + harness.structure.mockClear(); + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(entities.slice(half)), + }); + + expect(harness.structure).toHaveBeenCalledTimes(1); + expect(harness.lastFlatCount()).toBe(FLAT_FORCE.nodeCount); + }); +}); + +describe("GraphWorker.commitStructure — streaming equals bulk", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("reaches the same committed node/link/flat state whether batched or bulk", () => { + const entities = buildIngestEntities(COMMUNITY_FORCE); + + const bulk = prime(COMMUNITY_FORCE); + + const streamed = newHarness(); + streamed.worker.registerTypes( + benchTypeSchemas(COMMUNITY_FORCE.typeCount), + [], + ); + const batchSize = 100; + for (let start = 0; start < entities.length; start += batchSize) { + const chunk = entities.slice(start, start + batchSize); + streamed.worker.commitStructure({ + deltas: streamed.worker.ingestBatch(chunk), + }); + } + + expect(streamed.worker.nodeCount).toBe(bulk.worker.nodeCount); + expect(streamed.worker.linkCount).toBe(bulk.worker.linkCount); + expect(streamed.worker.mode).toBe(bulk.worker.mode); + expect(streamed.lastFlatCount()).toBe(bulk.lastFlatCount()); + expect(streamed.lastFlatCount()).toBe(COMMUNITY_FORCE.nodeCount); + }); +}); + +/** Above `communityColorExitNodes` (5000) the worker enters the hierarchical tier. */ +const HIERARCHICAL: GraphShape = { + nodeCount: 6_000, + linkCount: 12_000, + typeCount: 20, + hubCount: 60, + rootFraction: 1, + seed: 13, +}; + +describe("GraphWorker.commitStructure — hierarchical tier", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("commits clusters and reuses handleViewport's cut without re-emitting stale state", () => { + const harness = prime(HIERARCHICAL); + expect(harness.worker.mode).toBe("hierarchical-lod"); + + // A viewport change computes a cut then commits it; the commit must reuse + // that cut (F10) and emit a hierarchical (cluster) frame, not a flat one. + harness.structure.mockClear(); + harness.worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + const frames = harness.structure.mock.calls.map( + ([frame]) => frame as { mode: string; flatGraph?: unknown }, + ); + expect(frames.length).toBeGreaterThan(0); + for (const frame of frames) { + expect(frame.mode).toBe("hierarchical-lod"); + expect(frame.flatGraph).toBeUndefined(); + } + }); + + it("does no work on a redundant hierarchical re-commit", () => { + const harness = prime(HIERARCHICAL); + harness.worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + // Nothing changed since that commit: same tree/epoch, links, and cut. The + // no-op fast path must skip CutIndex/aggregation/layers and emit nothing. + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.worker.commitStructure(); + + expect(harness.structure).not.toHaveBeenCalled(); + expect(harness.positions).not.toHaveBeenCalled(); + }); + + it("does not commit when a pin leaves the cut unchanged", () => { + const harness = prime(HIERARCHICAL); + harness.worker.handleViewport({ + zoom: 1, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + // A cluster that isn't in the tree can't open a new path, so the cut is + // identical and pin() must not trigger a commit (it should mirror the + // wouldChange guard handleViewport uses). + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.worker.pin(ClusterId("cluster:does-not-exist")); + + expect(harness.structure).not.toHaveBeenCalled(); + expect(harness.positions).not.toHaveBeenCalled(); + }); + + // Top-level re-layout is driven by INGEST, not by type (re)registration: a + // trickle of new members leaves the overview stable (no churn on every tiny + // expansion), but once a cluster grows past the re-warm threshold the macro + // layout re-settles -- with no rebuildTree / REGISTER_TYPES involved. + it("re-arranges the top level on significant known-type growth, not on a trickle", () => { + const harness = prime(HIERARCHICAL); + // Zoomed out: every top-level cluster stays collapsed, so the emitted + // clusters ARE the macro layout's nodes. + harness.worker.handleViewport({ + zoom: 0.02, + centerX: 0, + centerY: 0, + width: 1600, + height: 900, + }); + + const before = topLevelSnapshot(harness); + expect(before.size).toBeGreaterThan(1); + + // A trickle: a few nodes spread across existing types (already-known). Every + // top-level cluster grows a little, but none crosses the re-warm threshold. + const trickle: IngestEntity[] = []; + for (let index = 0; index < 40; index++) { + trickle.push({ + entityId: benchEntityId(1_000_000 + index), + entityTypeIds: [benchNodeTypeUrl(index % HIERARCHICAL.typeCount)], + isLink: false, + isRoot: true, + }); + } + harness.structure.mockClear(); + harness.positions.mockClear(); + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(trickle), + }); + const afterTrickle = topLevelSnapshot(harness); + + // The growth WAS applied (member counts rose) ... + expect( + [...afterTrickle].some( + ([id, cluster]) => cluster.count > (before.get(id)?.count ?? 0), + ), + ).toBe(true); + // ... yet no single cluster grew enough to re-warm: the overview holds. + expect(positionDrift(before, afterTrickle)).toBe(0); + + // A real expansion: many nodes into ONE existing type. That cluster grows + // past the threshold, so the macro layout re-warms and the top level moves + // -- driven purely by ingest, with NO rebuildTree / type registration. + const surge: IngestEntity[] = []; + for (let index = 0; index < 400; index++) { + surge.push({ + entityId: benchEntityId(2_000_000 + index), + entityTypeIds: [benchNodeTypeUrl(0)], + isLink: false, + isRoot: true, + }); + } + harness.worker.commitStructure({ + deltas: harness.worker.ingestBatch(surge), + }); + const afterSurge = topLevelSnapshot(harness); + + expect(positionDrift(afterTrickle, afterSurge)).toBeGreaterThan(0); + }); +}); + +describe("GraphWorker.registerTypes — change classification", () => { + it("reports no change on an identical re-registration", () => { + const worker = new GraphWorker(defaultVizConfig); + const schemas = benchTypeSchemas(8); + + expect(worker.registerTypes(schemas, []).typesChanged).toBe(true); + + const again = worker.registerTypes(schemas, []); + expect(again.typesChanged).toBe(false); + expect(again.propertyTitlesChanged).toBe(false); + }); + + it("reports a change when a genuinely new type is registered", () => { + const worker = new GraphWorker(defaultVizConfig); + worker.registerTypes(benchTypeSchemas(8), []); + + // benchTypeSchemas(10) is a superset -- two new node types -- which can + // change grouping/colour and so must be reported as a change. + expect(worker.registerTypes(benchTypeSchemas(10), []).typesChanged).toBe( + true, + ); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts index ea147f8b9d2..7de1d8b716f 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts @@ -3,6 +3,7 @@ import { dimColor } from "../../dim-color"; import { ClusterId } from "../../ids"; import { graphColors } from "../../visual-style"; import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { Column } from "../collections/column"; import { ReadonlySortedSet } from "../collections/readonly-sorted-set"; import { colorForType, @@ -45,7 +46,7 @@ import { LinkStore } from "../store/link"; import { PropertyStore } from "../store/property"; import { TypeRegistry } from "../store/type-registry"; import { TypeSetStore } from "../store/type-set"; -import { layoutNeedsRebuild } from "./layout-reuse"; +import { layoutNeedsRebuild, layoutOutgrown } from "./layout-reuse"; import { viewportAnchorWeight } from "./viewport-anchor"; import type { VizConfig } from "../../config"; @@ -72,7 +73,7 @@ import type { RepublishHandler } from "../buffers/growable-buffer"; import type { Port } from "../geometry/bubble-ports"; import type { EdgeFrame } from "../geometry/edge-aggregation"; import type { ClusterNode, IngestDelta } from "../hierarchy/cluster-tree"; -import type { ViewportState } from "../hierarchy/lod"; +import type { LodItem, ViewportState } from "../hierarchy/lod"; import type { ForceEdge, ForceNode, @@ -154,6 +155,8 @@ const FLAT_SEED_NEIGHBOUR_OFFSET = 24; const FLAT_SEED_DISK_SCALE = 28; /** Flat-tier edge stroke width in world units (the layer scales it with zoom). */ const FLAT_EDGE_WIDTH_WORLD = 1.2; +/** Initial capacity for the flat-tier node-index column (grows by doubling). */ +const FLAT_NODE_IDX_CAPACITY = 4096; /** Over-allocate capacity so streamed nodes can append without reallocation. */ function flatCapacityFor(count: number): number { return Math.max(count + 64, Math.ceil(count * 1.5)); @@ -190,6 +193,15 @@ export class GraphWorker { readonly #bezierSink = new BezierSegmentSink(); readonly #pendingEmbeddingRequests: EmbeddingClusteringNeededMessage[] = []; readonly #forceLayouts = new Map(); + /** + * Entity-index to local-slot map per leaf layout. Keyed on the layout + * object so it invalidates automatically when the node set changes. + */ + readonly #leafLocalCache = new WeakMap< + LayoutSimulation, + ReadonlyMap + >(); + /** Per entity-layout, the live port-attraction targets (shared with its force). */ readonly #entityPortTargets = new Map(); /** Per opened container, the external endpoint ids its port anchors track. */ @@ -223,6 +235,15 @@ export class GraphWorker { #mode: VizMode = "flat-force"; /** Loaded node entities (excludes interned links). */ #nodeEntityCount = 0; + /** + * Node entity indices, always sorted ascending. Interner indices are + * monotonic, so appending on insert preserves the sort invariant. + */ + readonly #nodeEntityIdxs = new Column( + Int32Array, + FLAT_NODE_IDX_CAPACITY, + ); + /** True when the committed state is the hierarchical (cluster-tree) regime. */ #hierarchicalActive = false; /** Flat-tier render edges (one per link: local indices + link-type colour), @@ -257,6 +278,11 @@ export class GraphWorker { /** Cached topology from the last structure commit; reused by position ticks. */ #cutIndex: CutIndex | undefined; #edgeFrame: EdgeFrame | undefined; + /** Bumped on every cluster-tree mutation; compared against committed values to detect no-ops. */ + #clusterEpoch = 0; + /** Epoch and link count as of the last emitted hierarchical structure frame. */ + #committedClusterEpoch = -1; + #committedLinkCount = -1; /** Per-lane link-entity unions, indexed by laneId. A merged highway's * lanes share one union (the whole ribbon's links). */ #highwayLaneUnions: EntityIndex[][] = []; @@ -389,6 +415,7 @@ export class GraphWorker { #tickAllLayouts(): void { const tickStart = performance.now(); const clustersRunningBefore = this.#anyClusterLayoutRunning(); + const layoutsRunningBefore = this.#anyLayoutRunning(); let clusterMoved = false; let flatMoved = false; @@ -448,6 +475,10 @@ export class GraphWorker { // tick where the last cluster layout settles (so `settled` reaches main). const clustersJustSettled = clustersRunningBefore && !this.#anyClusterLayoutRunning(); + // Emit one last frame when the final layout settles, so the renderer + // receives exactly one `settled: true` positions frame. + const layoutsJustSettled = + layoutsRunningBefore && !this.#anyLayoutRunning(); if (clusterMoved || clustersJustSettled) { // Recompose world positions top-down so anchor re-aiming reads correct, // fully-propagated circles through settled nested layouts. @@ -457,7 +488,7 @@ export class GraphWorker { this.#updateAnchorTracking(); } this.#emitPositions(); - } else if (flatMoved) { + } else if (flatMoved || layoutsJustSettled) { this.#emitPositions(); } @@ -520,12 +551,21 @@ export class GraphWorker { return [...targets.values()]; } + /** + * Register type and property schemas. Returns what changed so the caller + * can decide whether a commit is needed. + */ registerTypes( schemas: readonly TypeSchemaEntry[], propertySchemas: readonly PropertySchemaEntry[], - ): void { - this.#types.registerAll(schemas); - this.#properties.registerTitles(propertySchemas); + ): { + readonly typesChanged: boolean; + readonly propertyTitlesChanged: boolean; + } { + const typesChanged = this.#types.registerAll(schemas); + const propertyTitlesChanged = + this.#properties.registerTitles(propertySchemas); + return { typesChanged, propertyTitlesChanged }; } /** Insert a node entity. Returns undefined if duplicate. */ @@ -546,6 +586,8 @@ export class GraphWorker { return undefined; } this.#nodeEntityCount += 1; + // Interner indices are monotonic, so this stays sorted (see #nodeEntityIdxs). + this.#nodeEntityIdxs.push(entityIdx); const directTypeIdxs = new ReadonlySortedSet( entity.entityTypeIds.map((url) => this.#types.intern(url)), @@ -719,6 +761,7 @@ export class GraphWorker { this.#edgeFrame = undefined; this.#clusterTree.rebuild(this.#typeSets, this.#types, this.config); + this.#clusterEpoch += 1; } /** @@ -732,6 +775,7 @@ export class GraphWorker { this.#types, this.config, ); + this.#clusterEpoch += 1; } get hasClusters(): boolean { @@ -766,7 +810,8 @@ export class GraphWorker { ); if (this.#lodState.wouldChange(cut)) { - this.commitStructure(); + // Reuse the just-computed cut instead of recomputing it. + this.commitStructure({ cut }); } } @@ -776,8 +821,27 @@ export class GraphWorker { return; } this.#pinnedLeaf = leafId; - if (this.#mode === "hierarchical-lod" && this.hasClusters) { - this.commitStructure(); + if (this.#mode !== "hierarchical-lod" || !this.hasClusters) { + return; + } + + // No viewport yet; the next commit will honour the pin. + if (!this.#viewport) { + return; + } + + // Only commit when the pin actually changes the visible cut. + const cut = computeVisibleCut( + this.#clusterTree, + ROOT_ID, + this.#viewport, + this.#lodState, + this.config, + (node) => this.#trySubdivide(node), + this.#pinnedOpenSet(), + ); + if (this.#lodState.wouldChange(cut)) { + this.commitStructure({ cut }); } } @@ -846,6 +910,8 @@ export class GraphWorker { commitStructure(opts?: { readonly deltas?: readonly IngestDelta[]; readonly rebuildTree?: boolean; + /** Precomputed visible cut; ignored when the tree was mutated by this commit. */ + readonly cut?: readonly LodItem[]; }): void { this.recomputeMode(); this.#publishEntityIdMapOnce(); @@ -858,7 +924,7 @@ export class GraphWorker { } this.#hierarchicalActive = false; - this.#commitFlat(); + this.#commitFlat(opts); return; } @@ -867,11 +933,15 @@ export class GraphWorker { } // Rebuild the tree on first build, re-entry, or type changes; - // otherwise apply incremental deltas. + // otherwise apply incremental deltas. Either mutates the tree, which + // invalidates any cut the caller precomputed against the old tree. + let treeMutated = false; if (opts?.rebuildTree || !this.#hierarchicalActive || !this.hasClusters) { this.rebuildClusters(); + treeMutated = true; } else if (opts?.deltas && opts.deltas.length > 0) { this.updateClusters(opts.deltas); + treeMutated = true; } this.#hierarchicalActive = true; @@ -910,15 +980,33 @@ export class GraphWorker { return; } - const cut = computeVisibleCut( - this.#clusterTree, - ROOT_ID, - this.#viewport, - this.#lodState, - this.config, - (node) => this.#trySubdivide(node), - this.#pinnedOpenSet(), - ); + // Reuse the caller's precomputed cut when the tree wasn't mutated + // (a rebuild/incremental update invalidates it). + const cut = + opts?.cut && !treeMutated + ? opts.cut + : computeVisibleCut( + this.#clusterTree, + ROOT_ID, + this.#viewport, + this.#lodState, + this.config, + (node) => this.#trySubdivide(node), + this.#pinnedOpenSet(), + ); + + // No-op fast path: if tree, links, root status, and cut are all unchanged + // since the last emit, the derived state would be identical. + if ( + this.#cutIndex !== undefined && + this.#clusterEpoch === this.#committedClusterEpoch && + this.#links.count === this.#committedLinkCount && + !this.#rootFlipPending && + !this.#lodState.wouldChange(cut) + ) { + return; + } + this.#lodState.applyVisibleCut(cut); const openIds = new Set(); @@ -991,16 +1079,23 @@ export class GraphWorker { this.#emitStructure(this.#buildEntityLayers(cutIndex)); this.#emitPositions(); + + // Snapshot dependency versions for the no-op fast path. + this.#committedClusterEpoch = this.#clusterEpoch; + this.#committedLinkCount = this.#links.count; } /** * Commit the flat tier: the whole entity set as one individual-entity graph. - * Builds or warm-updates the flat layout, then emits per-node style. + * + * Detects topology changes via O(1) node/link count comparison (stores are + * add-only). Returns immediately when nothing changed. A colour-only change + * (type registration or root flip) restyles without rebuilding the layout. */ - #commitFlat(): void { - const entityIdxs = this.#allNodeEntityIdxs(); + #commitFlat(opts?: { readonly rebuildTree?: boolean }): void { + const nodeCount = this.#nodeEntityCount; - if (entityIdxs.length === 0) { + if (nodeCount === 0) { this.#tearDownFlat(); this.#rendered = []; this.#renderedIndex.clear(); @@ -1009,32 +1104,45 @@ export class GraphWorker { return; } - // Community-force can warm-absorb additions without a full rebuild. - // Otherwise (first build, mode switch, removal) rebuild from scratch. const existing = this.#forceLayouts.get(FLAT_LAYOUT_ID); - const priorBuffer = this.#flatBuffer; const modeChanged = this.#flatLayoutMode !== this.#mode; + // Add-only stores: a count delta versus the live layout's built count (and + // versus the link count at that build) captures every topology change. + const builtNodeCount = existing?.nodes.length ?? -1; + const nodesChanged = nodeCount !== builtNodeCount; + const linksChanged = this.#links.count !== this.#flatLinkCount; + const structureChanged = + !existing || modeChanged || nodesChanged || linksChanged; + + // A type registration (rebuildTree) or a frontier->root flip recolours + // nodes without changing topology; restyle in place, no layout rebuild. + const styleDirty = opts?.rebuildTree === true || this.#rootFlipPending; + + if (!structureChanged && !styleDirty) { + // Nothing changed: the layout keeps streaming positions via the + // scheduler, no frame or buffer write needed. + return; + } - let topologyChanged = !existing; - let canAbsorb = false; - if (existing && priorBuffer) { - const idxSet = new Set(entityIdxs); - const currentIds = new Set(existing.nodeIds); - const added = entityIdxs.some((idx) => !currentIds.has(String(idx))); - const removed = existing.nodeIds.some((id) => !idxSet.has(Number(id))); - topologyChanged = - added || removed || this.#links.count !== this.#flatLinkCount; - canAbsorb = + if (structureChanged) { + // Materialise the packed column into a plain array for the layout builders + // (they map/filter/spread it). Only reached on a real structural change -- + // the no-op path above never allocates. + const entityIdxs = [...this.#nodeEntityIdxs]; + // community-force (FA2) can warm-absorb additions in place; everything + // else (first build, mode switch, or a shrink -- impossible with add-only + // stores, handled defensively) rebuilds, warm-seeded from current spots. + const canAbsorb = + !!existing && !modeChanged && - !removed && + nodeCount >= builtNodeCount && this.#mode === "community-force" && typeof existing.absorb === "function"; - } - - if (!existing || modeChanged || (topologyChanged && !canAbsorb)) { - this.#rebuildFlatLayout(entityIdxs); - } else if (topologyChanged) { - this.#absorbFlatNodes(existing, entityIdxs); + if (canAbsorb) { + this.#absorbFlatNodes(existing, entityIdxs); + } else { + this.#rebuildFlatLayout(entityIdxs); + } } const layout = this.#forceLayouts.get(FLAT_LAYOUT_ID); @@ -1045,13 +1153,16 @@ export class GraphWorker { return; } - // Per-node radius + colour straight into the shared buffer, and the per-link - // render edges for the bezier emission. Both refresh every commit so colours - // track a type change even when the layout itself was not rebuilt. + // Per-node radius + colour into the shared buffer, plus the per-link render + // edges for the bezier emission. Both run on any structural OR colour change + // (so colours track a type change even when the layout was reused), and are + // skipped on the no-op path above. this.#writeFlatStyle(layout, buffer); this.#flatRenderEdges = this.#buildFlatRenderEdges(layout); this.#emitFlatFrame(layout); - this.#scheduleFlatLouvainLinger(); + if (structureChanged) { + this.#scheduleFlatLouvainLinger(); + } } /** Emit the flat structure frame (count + Louvain membership) and positions. */ @@ -1086,18 +1197,6 @@ export class GraphWorker { }, FLAT_LOUVAIN_LINGER_MS); } - /** All loaded node entities, sorted by index. */ - #allNodeEntityIdxs(): EntityIndex[] { - const result: EntityIndex[] = []; - for (const group of this.#typeSets) { - for (const idx of group.entities) { - result.push(idx); - } - } - result.sort((lhs, rhs) => lhs - rhs); - return result; - } - /** Publish the join-map SharedArrayBuffer to the main thread on first use. */ #publishEntityIdMapOnce(): void { if (this.#entityIdMapPublished) { @@ -1753,7 +1852,8 @@ export class GraphWorker { this.#positionVersion++; this.#onPositionsFrame?.({ version: this.#positionVersion, - settled: !this.#anyClusterLayoutRunning(), + // True once every layout (cluster and entity/flat) has settled. + settled: !this.#anyLayoutRunning(), clusterPositions, beziers, edgeLabels, @@ -1823,6 +1923,20 @@ export class GraphWorker { return frontier; } + /** Entity-index to local-slot map for a leaf, cached on the layout object. */ + #leafLocalOf(layout: LayoutSimulation): ReadonlyMap { + const cached = this.#leafLocalCache.get(layout); + if (cached) { + return cached; + } + const localOf = new Map(); + for (let idx = 0; idx < layout.nodeIds.length; idx++) { + localOf.set(Number(layout.nodeIds[idx]) as EntityIndex, idx); + } + this.#leafLocalCache.set(layout, localOf); + return localOf; + } + /** Build per-leaf entity-edge topology for the structure frame. */ #buildEntityLayers(cutIndex: CutIndex): RenderEntityLayer[] { const layers: RenderEntityLayer[] = []; @@ -1835,10 +1949,7 @@ export class GraphWorker { continue; } - const localOf = new Map(); - for (let idx = 0; idx < layout.nodeIds.length; idx++) { - localOf.set(Number(layout.nodeIds[idx]) as EntityIndex, idx); - } + const localOf = this.#leafLocalOf(layout); // Internal entity-to-entity links (both endpoints owned by this leaf). const internal: number[] = []; @@ -1917,10 +2028,7 @@ export class GraphWorker { continue; } - const localOf = new Map(); - for (let idx = 0; idx < layout.nodeIds.length; idx++) { - localOf.set(Number(layout.nodeIds[idx]) as EntityIndex, idx); - } + const localOf = this.#leafLocalOf(layout); const exitForOwner = new Map< ClusterId, @@ -2179,6 +2287,25 @@ export class GraphWorker { ); } + /** + * Whether a top-level cluster has grown enough since the macro layout was built + * to warrant re-warming it, so a growing hierarchy re-arranges even without an + * overlap. See {@link layoutOutgrown}. `layout.nodes[i].radius` is the radius the + * layout was built with; `child.circle.radius` is the current (grown) one. + */ + #clusterLayoutOutgrown( + layout: LayoutSimulation, + parent: ClusterNode, + ): boolean { + return layoutOutgrown( + layout.nodes.map((node) => ({ id: node.id, radius: node.radius })), + parent.children.map((child) => ({ + id: child.id, + radius: child.circle.radius, + })), + ); + } + #ensureChildrenLayout(parent: ClusterNode): void { const key = parent.id; let layout = this.#forceLayouts.get(key); @@ -2191,9 +2318,16 @@ export class GraphWorker { this.#snapshotTopLevelPositions(layout); } - // Invalidate when a freshly-sized child overlaps a neighbour at its - // frozen position; harmless growth with slack around it is kept. - if (layout && this.#clusterLayoutStale(layout, parent)) { + // Invalidate when a freshly-sized child overlaps a neighbour at its frozen + // position (harmless growth with slack around it is kept), OR — top level + // only — when a cluster has grown enough since this layout was built to + // warrant a re-pack, so the hierarchy overview visibly re-arranges as it + // grows rather than only when growth finally forces an overlap. + if ( + layout && + (this.#clusterLayoutStale(layout, parent) || + (parent.kind === "root" && this.#clusterLayoutOutgrown(layout, parent))) + ) { this.#forceLayouts.delete(key); this.#anchorEndpoints.delete(key); layout = undefined; @@ -2588,6 +2722,7 @@ export class GraphWorker { if (!subdivided) { return false; } + this.#clusterEpoch += 1; // If children are entity-buckets (deterministic partition), // queue an embedding request to upgrade them. @@ -2702,6 +2837,7 @@ export class GraphWorker { }); this.#clusterTree.applyEmbeddingResult(clusterId, assignments); + this.#clusterEpoch += 1; // The children render immediately with their "Similar group n" placeholder (set in // ClusterTree.applyEmbeddingResult); the relabel lands later off the job scheduler. @@ -2736,10 +2872,29 @@ export class GraphWorker { for (const [childId, label] of labels) { this.#clusterTree.setLabelText(childId, label); } - this.commitStructure(); + // Labels don't affect the cut, so re-emit the current topology with the + // fresh labels rather than paying a full cut + aggregation rebuild. + this.#recommitLabelsOnly(); }); } + /** + * Re-emit the structure frame with the current cluster labels, reusing the + * cached cut, {@link CutIndex}, and edge aggregation. Labels are read fresh + * from the tree in {@link #renderCluster}, so this shows updated names without + * recomputing topology. Falls back to a full commit if the cached topology is + * gone (mode switched, or nothing committed yet). + */ + #recommitLabelsOnly(): void { + const cutIndex = this.#cutIndex; + if (this.#mode !== "hierarchical-lod" || !cutIndex || !this.#edgeFrame) { + this.commitStructure(); + return; + } + this.#emitStructure(this.#buildEntityLayers(cutIndex)); + this.#emitPositions(); + } + #resolvePendingLinks( entityId: IngestEntity["entityId"], entityIdx: EntityIndex, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts index bdcc3984e00..951c4c64518 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { + GROWTH_RELAYOUT_TOLERANCE_FRAC, layoutNeedsRebuild, + layoutOutgrown, OVERLAP_REBUILD_TOLERANCE_FRAC, } from "./layout-reuse"; @@ -91,3 +93,45 @@ describe("layoutNeedsRebuild", () => { ).toBe(true); }); }); + +describe("layoutOutgrown", () => { + const buildTime = [ + { id: "a", radius: 40 }, + { id: "b", radius: 40 }, + ] as const; + + it("re-warms once a child grows past the threshold", () => { + const past = 40 * (1 + GROWTH_RELAYOUT_TOLERANCE_FRAC) + 1; + expect( + layoutOutgrown(buildTime, [ + { id: "a", radius: past }, + { id: "b", radius: 40 }, + ]), + ).toBe(true); + }); + + it("keeps the layout while growth stays within the threshold", () => { + const within = 40 * (1 + GROWTH_RELAYOUT_TOLERANCE_FRAC) - 1; + expect( + layoutOutgrown(buildTime, [ + { id: "a", radius: within }, + { id: "b", radius: within }, + ]), + ).toBe(false); + }); + + it("never re-warms on a shrink", () => { + expect( + layoutOutgrown(buildTime, [ + { id: "a", radius: 10 }, + { id: "b", radius: 5 }, + ]), + ).toBe(false); + }); + + it("ignores ids not present when the layout was built", () => { + // A genuinely new cluster is layoutNeedsRebuild's job (count change), not + // this growth check — so an unknown id alone must not trigger a re-warm. + expect(layoutOutgrown(buildTime, [{ id: "z", radius: 9_999 }])).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts index fd550f054f5..ef268fa1379 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/layout-reuse.ts @@ -73,3 +73,37 @@ export function layoutNeedsRebuild( } return false; } + +/** + * How much a child may grow (as a fraction of its build-time radius) before + * the layout is re-warmed. Separate from {@link layoutNeedsRebuild} (which + * fires on infeasible overlaps): this is a voluntary re-pack so growing + * clusters visibly re-arrange before any overlap occurs. The threshold + * prevents re-warming on every streaming batch. + * + * Radius grows as `sqrt(count)`, so 0.15 ≈ +32% members. + */ +export const GROWTH_RELAYOUT_TOLERANCE_FRAC = 0.15; + +/** + * Whether any child's radius has grown past `toleranceFrac` of the radius it had + * when the layout was built (`buildTime`). Ids only present on one side are + * ignored — {@link layoutNeedsRebuild} owns set-membership changes. + */ +export function layoutOutgrown( + buildTime: readonly SizedNode[], + current: readonly SizedNode[], + toleranceFrac: number = GROWTH_RELAYOUT_TOLERANCE_FRAC, +): boolean { + const builtRadiusById = new Map(); + for (const node of buildTime) { + builtRadiusById.set(node.id, node.radius); + } + for (const child of current) { + const built = builtRadiusById.get(child.id); + if (built !== undefined && child.radius > built * (1 + toleranceFrac)) { + return true; + } + } + return false; +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts index c8c23322faa..da92d7a3bfa 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/entry.ts @@ -81,10 +81,18 @@ globalThis.onmessage = ({ data }: MessageEvent) => { } try { - worker.registerTypes(data.typeSchemas, data.propertySchemas); - // Types drive labels/colours; force a fresh hierarchical rebuild (a no-op - // for the flat tiers, which recompute colours from the registry anyway). - worker.commitStructure({ rebuildTree: true }); + const { typesChanged } = worker.registerTypes( + data.typeSchemas, + data.propertySchemas, + ); + // Only a genuinely new type changes grouping/colour, so only then does + // the tree need rebuilding. Identical re-registrations (the common case) + // and property-only additions do not: top-level re-layout as the graph + // grows is driven by ingest (see #clusterLayoutOutgrown), not by type + // registration, so skipping this no longer freezes the overview. + if (typesChanged) { + worker.commitStructure({ rebuildTree: true }); + } } catch (err) { post({ type: "ERROR", message: String(err) }); } diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts new file mode 100644 index 00000000000..efaf2f58c99 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts @@ -0,0 +1,139 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; +import { Rectangle, removeOverlaps as colaRemoveOverlaps } from "webcola"; + +import { mulberry32 } from "../../math/random"; +import { VpscOverlapRemover } from "./overlap-removal"; + +interface RectSet { + x: Float32Array; + y: Float32Array; + halfW: Float32Array; + halfH: Float32Array; + count: number; +} + +function coincident(count: number, jitter: number): RectSet { + const rng = mulberry32(7); + const x = new Float32Array(count); + const y = new Float32Array(count); + const halfW = new Float32Array(count).fill(6); + const halfH = new Float32Array(count).fill(6); + for (let i = 0; i < count; i++) { + x[i] = (rng() - 0.5) * jitter; + y[i] = (rng() - 0.5) * jitter; + } + return { x, y, halfW, halfH, count }; +} + +function ring(count: number, radius: number, half: number): RectSet { + const x = new Float32Array(count); + const y = new Float32Array(count); + const halfW = new Float32Array(count).fill(half); + const halfH = new Float32Array(count).fill(half); + for (let i = 0; i < count; i++) { + const angle = (i / count) * Math.PI * 2; + x[i] = Math.cos(angle) * radius; + y[i] = Math.sin(angle) * radius; + } + return { x, y, halfW, halfH, count }; +} + +function hubRing(count: number, radius: number): RectSet { + const set = ring(count - 1, radius, 5); + const x = new Float32Array(count); + const y = new Float32Array(count); + const halfW = new Float32Array(count); + const halfH = new Float32Array(count); + x[0] = 0; + y[0] = 0; + halfW[0] = 10; + halfH[0] = 10; + for (let i = 1; i < count; i++) { + x[i] = set.x[i - 1]!; + y[i] = set.y[i - 1]!; + halfW[i] = 5; + halfH[i] = 5; + } + return { x, y, halfW, halfH, count }; +} + +function clustered(count: number): RectSet { + const rng = mulberry32(11); + const x = new Float32Array(count); + const y = new Float32Array(count); + const halfW = new Float32Array(count).fill(6); + const halfH = new Float32Array(count).fill(6); + const clusterCount = Math.max(1, Math.round(count / 150)); + for (let i = 0; i < count; i++) { + const cluster = i % clusterCount; + const cx = (cluster % 40) * 400; + const cy = Math.floor(cluster / 40) * 400; + x[i] = cx + (rng() - 0.5) * 20; + y[i] = cy + (rng() - 0.5) * 20; + } + return { x, y, halfW, halfH, count }; +} + +function overlappingPairs(set: RectSet, eps = 1e-2): number { + const { x, y, halfW, halfH, count } = set; + let pairs = 0; + for (let a = 0; a < count; a++) { + for (let b = a + 1; b < count; b++) { + const penX = halfW[a]! + halfW[b]! - Math.abs(x[a]! - x[b]!); + const penY = halfH[a]! + halfH[b]! - Math.abs(y[a]! - y[b]!); + if (penX > eps && penY > eps) { + pairs += 1; + } + } + } + return pairs; +} + +function timeMine(set: RectSet): string { + const remover = new VpscOverlapRemover(set.count); + const start = performance.now(); + remover.removeOverlaps(set.x, set.y, set.halfW, set.halfH, set.count); + const ms = performance.now() - start; + return `mine ms=${ms.toFixed(0)} overlaps=${overlappingPairs(set)} outer=${remover.statOuter} inner=${remover.statSatisfyInner} numCon=${remover.statMaxNumCon} cleanup=${remover.statCleanupRounds}`; +} + +function timeCola(set: RectSet): string { + const rects = new Array(set.count); + for (let i = 0; i < set.count; i++) { + rects[i] = new Rectangle( + set.x[i]! - set.halfW[i]!, + set.x[i]! + set.halfW[i]!, + set.y[i]! - set.halfH[i]!, + set.y[i]! + set.halfH[i]!, + ); + } + const start = performance.now(); + colaRemoveOverlaps(rects); + const ms = performance.now() - start; + return `cola ms=${ms.toFixed(0)}`; +} + +describe("perf probe", () => { + it("adversarial", () => { + const lines: string[] = []; + const cases: [string, RectSet][] = [ + ["150-exact", coincident(150, 0)], + ["1000-exact", coincident(1000, 0)], + ["1000-jit1", coincident(1000, 1)], + ["2000-exact", coincident(2000, 0)], + ["5000-exact", coincident(5000, 0)], + ["5000-clustered", clustered(5000)], + ["150-hubRing40", hubRing(150, 40)], + ]; + for (const [name, set] of cases) { + const before = overlappingPairs(set); + if (name.endsWith("cola")) { + lines.push(`${name}: ${timeCola(set)}`); + } else { + lines.push(`${name}(pre=${before}): ${timeMine(set)}`); + } + } + expect(lines.join("\n")).toBe(""); + }, 60000); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts new file mode 100644 index 00000000000..f9035582ae7 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts @@ -0,0 +1,39 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { buildForceGraph } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createStressLayout } from "./stress-layout"; + +import type { GraphShape } from "../bench-fixtures"; + +describe("probe 5000 projection", () => { + it("stats", () => { + const shape: GraphShape = { + nodeCount: 5000, + linkCount: 13000, + typeCount: 1, + hubCount: 125, + rootFraction: 1, + seed: 303, + }; + const { nodes, edges } = buildForceGraph(shape); + const buffer = new FlatGraphBuffer(nodes.length); + const layout = createStressLayout(nodes, edges, buffer); + for (let step = 0; step < 10_000_000 && !layout.isSettled; step++) { + if (!layout.tick(1)) { + break; + } + } + const diag = layout as unknown as { + overlapProjectionMs: number; + statOuter: number; + statNumCon: number; + statCleanup: number; + statInner: number; + }; + expect( + `projMs=${diag.overlapProjectionMs.toFixed(1)} outer=${diag.statOuter} inner=${diag.statInner} numCon=${diag.statNumCon} cleanup=${diag.statCleanup}`, + ).toBe(""); + }, 60000); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.test.ts new file mode 100644 index 00000000000..3f43e7005a2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.test.ts @@ -0,0 +1,257 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; +import { Rectangle, removeOverlaps as colaRemoveOverlaps } from "webcola"; + +import { mulberry32 } from "../../math/random"; +import { VpscOverlapRemover } from "./overlap-removal"; + +interface RectSet { + x: Float32Array; + y: Float32Array; + halfW: Float32Array; + halfH: Float32Array; + count: number; +} + +/** + * Random axis-aligned rectangles packed into a square whose side scales with the + * average size, so overlaps are common but the set is not a single pile-up. + */ +function randomRects( + count: number, + seed: number, + sizeJitter = true, + spanPerNode = 12, +): RectSet { + const rng = mulberry32(seed); + const x = new Float32Array(count); + const y = new Float32Array(count); + const halfW = new Float32Array(count); + const halfH = new Float32Array(count); + const span = Math.sqrt(count) * spanPerNode; + for (let index = 0; index < count; index++) { + x[index] = rng() * span; + y[index] = rng() * span; + halfW[index] = sizeJitter ? 3 + rng() * 9 : 6; + halfH[index] = sizeJitter ? 3 + rng() * 9 : 6; + } + return { x, y, halfW, halfH, count }; +} + +/** + * Direct O(n²) overlap check. Two rectangles overlap when their x-intervals and + * y-intervals both interpenetrate by more than `eps` — the small tolerance + * absorbs the Float32 rounding of the (Float64) solver output. + */ +function overlappingPairs(set: RectSet, eps = 1e-2): number { + const { x, y, halfW, halfH, count } = set; + let pairs = 0; + for (let a = 0; a < count; a++) { + for (let b = a + 1; b < count; b++) { + const penX = halfW[a]! + halfW[b]! - Math.abs(x[a]! - x[b]!); + const penY = halfH[a]! + halfH[b]! - Math.abs(y[a]! - y[b]!); + if (penX > eps && penY > eps) { + pairs += 1; + } + } + } + return pairs; +} + +function totalDisplacement( + set: RectSet, + origX: Float32Array, + origY: Float32Array, +): number { + let sum = 0; + for (let index = 0; index < set.count; index++) { + const dx = set.x[index]! - origX[index]!; + const dy = set.y[index]! - origY[index]!; + sum += Math.hypot(dx, dy); + } + return sum; +} + +function clone(set: RectSet): RectSet { + return { + x: Float32Array.from(set.x), + y: Float32Array.from(set.y), + halfW: Float32Array.from(set.halfW), + halfH: Float32Array.from(set.halfH), + count: set.count, + }; +} + +describe("VpscOverlapRemover", () => { + it("removes all overlaps in random rectangle sets", () => { + for (const seed of [1, 2, 3, 7, 42]) { + const set = randomRects(400, seed); + expect(overlappingPairs(set)).toBeGreaterThan(0); + + const remover = new VpscOverlapRemover(set.count); + remover.removeOverlaps(set.x, set.y, set.halfW, set.halfH, set.count); + + expect(overlappingPairs(set)).toBe(0); + } + }); + + it("resolves a heavy uniform-size pile-up (all centres at the origin)", () => { + const count = 200; + const set: RectSet = { + x: new Float32Array(count), + y: new Float32Array(count), + halfW: new Float32Array(count).fill(5), + halfH: new Float32Array(count).fill(5), + count, + }; + + new VpscOverlapRemover(count).removeOverlaps( + set.x, + set.y, + set.halfW, + set.halfH, + count, + ); + + expect(overlappingPairs(set)).toBe(0); + }); + + it("resolves mixed-size overlaps", () => { + const set = randomRects(600, 99, true); + // A few deliberately large rectangles swallowing many small ones. + for (const big of [0, 10, 20, 30]) { + set.halfW[big] = 40; + set.halfH[big] = 40; + } + new VpscOverlapRemover(set.count).removeOverlaps( + set.x, + set.y, + set.halfW, + set.halfH, + set.count, + ); + expect(overlappingPairs(set)).toBe(0); + }); + + it("is deterministic (same input ⇒ identical output)", () => { + const first = randomRects(500, 12345); + const second = clone(first); + + new VpscOverlapRemover(first.count).removeOverlaps( + first.x, + first.y, + first.halfW, + first.halfH, + first.count, + ); + new VpscOverlapRemover(second.count).removeOverlaps( + second.x, + second.y, + second.halfW, + second.halfH, + second.count, + ); + + expect([...first.x]).toEqual([...second.x]); + expect([...first.y]).toEqual([...second.y]); + }); + + it("reuses one instance across differently sized inputs", () => { + const remover = new VpscOverlapRemover(64); + for (const [count, seed] of [ + [50, 1], + [300, 2], + [120, 3], + ] as const) { + const set = randomRects(count, seed); + remover.removeOverlaps(set.x, set.y, set.halfW, set.halfH, set.count); + expect(overlappingPairs(set)).toBe(0); + } + }); + + it("stays close to webcola's displacement (oracle)", () => { + for (const seed of [4, 8, 15, 16, 23]) { + const source = randomRects(300, seed); + const origX = Float32Array.from(source.x); + const origY = Float32Array.from(source.y); + + const ours = clone(source); + new VpscOverlapRemover(ours.count).removeOverlaps( + ours.x, + ours.y, + ours.halfW, + ours.halfH, + ours.count, + ); + expect(overlappingPairs(ours)).toBe(0); + + const rects = new Array(source.count); + for (let index = 0; index < source.count; index++) { + rects[index] = new Rectangle( + origX[index]! - source.halfW[index]!, + origX[index]! + source.halfW[index]!, + origY[index]! - source.halfH[index]!, + origY[index]! + source.halfH[index]!, + ); + } + colaRemoveOverlaps(rects); + const cola: RectSet = clone(source); + for (let index = 0; index < source.count; index++) { + cola.x[index] = rects[index]!.cx(); + cola.y[index] = rects[index]!.cy(); + } + + const oursTotal = totalDisplacement(ours, origX, origY); + const colaTotal = totalDisplacement(cola, origX, origY); + // Same algorithm as webcola, so displacement should be within a small margin. + expect(oursTotal).toBeLessThanOrEqual(colaTotal * 1.2 + 1); + } + }); + + it("handles a realistic n=5000 layout quickly (performance smoke)", () => { + // Overlap removal runs on an already-settled layout, where nodes are mostly spread + // and only local clusters overlap — not a solid pile-up. Spread the rectangles so a + // realistic fraction (~1.4k pairs here) overlap; a fully-packed pile-up is an order + // of magnitude slower but never occurs after the stress solver has run. + const set = randomRects(5000, 2024, true, 40); + expect(overlappingPairs(set)).toBeGreaterThan(0); + + const remover = new VpscOverlapRemover(set.count); + const start = performance.now(); + remover.removeOverlaps(set.x, set.y, set.halfW, set.halfH, set.count); + const elapsed = performance.now() - start; + + expect(overlappingPairs(set)).toBe(0); + // Typically ~30ms on this fixture; the generous bound only guards against a gross + // (e.g. accidental O(n²)) regression without flaking on a slow CI machine. + expect(elapsed).toBeLessThan(200); + }); + + it("leaves already-separated rectangles untouched", () => { + const count = 25; + const set: RectSet = { + x: new Float32Array(count), + y: new Float32Array(count), + halfW: new Float32Array(count).fill(2), + halfH: new Float32Array(count).fill(2), + count, + }; + for (let index = 0; index < count; index++) { + set.x[index] = (index % 5) * 100; + set.y[index] = Math.floor(index / 5) * 100; + } + const beforeX = Float32Array.from(set.x); + const beforeY = Float32Array.from(set.y); + + new VpscOverlapRemover(count).removeOverlaps( + set.x, + set.y, + set.halfW, + set.halfH, + count, + ); + + expect([...set.x]).toEqual([...beforeX]); + expect([...set.y]).toEqual([...beforeY]); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts index 58e632cad6e..a54436b51ec 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts @@ -38,6 +38,12 @@ const ZERO_UPPERBOUND = -1e-10; const LAGRANGIAN_TOLERANCE = -1e-4; const COST_TOLERANCE = 1e-4; +// Cap on scanline walk per side. Without this, a pile-up of k coincident +// rectangles generates O(k²) constraints. The i->i+1 chain is always +// preserved, so a capped run is still separated transitively; the residual +// sweep handles any leftover. +const MAX_SCAN_NEIGHBOURS = 8; + /** Bijective-ish 32-bit hash for deterministic, well-spread treap priorities. */ function hashU32(value: number): number { let x = value | 0; @@ -56,14 +62,15 @@ export class VpscOverlapRemover { #n = 0; #numCon = 0; - // Rectangle inputs for the current pass (owned by the caller, mutated in place). - #gx: Float32Array = new Float32Array(0); - #gy: Float32Array = new Float32Array(0); + // Internal Float64 centres: Float32 rounding between passes makes abutting + // rectangles read as overlapping (error ≫ MIN_SEP), causing spurious constraints. + #gx = new Float64Array(0); + #gy = new Float64Array(0); #ghalfW: Float32Array = new Float32Array(0); #ghalfH: Float32Array = new Float32Array(0); // Which centre coordinate the scanline is ordered by this pass (x or y array). - #slCenter: Float32Array = new Float32Array(0); + #slCenter = new Float64Array(0); // Per-variable VPSC state. #desired = new Float64Array(0); @@ -81,6 +88,7 @@ export class VpscOverlapRemover { #blockAlive = new Uint8Array(0); #blocksList = new Int32Array(0); #blocksLen = 0; + #blockSnapshot = new Int32Array(0); #freeBlocks = new Int32Array(0); #freeTop = 0; @@ -123,11 +131,27 @@ export class VpscOverlapRemover { #eventOrder = new Int32Array(0); #eventPos = new Float64Array(0); + // Residual-overlap cleanup (pairs the two-pass misses at float boundaries). + #resLeft = new Int32Array(0); + #resRight = new Int32Array(0); + #resCapacity = 0; + #resCount = 0; + #active = new Int32Array(0); + #minLm = 0; + // TEMP perf diagnostics + statOuter = 0; + statCleanupRounds = 0; + statMaxNumCon = 0; + statSatisfyInner = 0; + constructor(capacity: number) { this.#allocateNode(Math.max(1, capacity | 0)); this.#allocateConstraints(Math.max(16, capacity | 0)); + this.#resCapacity = Math.max(16, capacity | 0); + this.#resLeft = new Int32Array(this.#resCapacity); + this.#resRight = new Int32Array(this.#resCapacity); } /** @@ -147,13 +171,25 @@ export class VpscOverlapRemover { } this.#ensureNodeCapacity(n); this.#n = n; - this.#gx = x; - this.#gy = y; this.#ghalfW = halfW; this.#ghalfH = halfH; + this.statOuter = 0; + this.statCleanupRounds = 0; + this.statMaxNumCon = 0; + this.statSatisfyInner = 0; + for (let i = 0; i < n; i++) { + this.#gx[i] = x[i]!; + this.#gy[i] = y[i]!; + } this.#solveDimension(true); this.#solveDimension(false); + this.#cleanupResiduals(); + + for (let i = 0; i < n; i++) { + x[i] = this.#gx[i]!; + y[i] = this.#gy[i]!; + } } #solveDimension(isX: boolean): void { @@ -173,9 +209,140 @@ export class VpscOverlapRemover { } } - // --------------------------------------------------------------------------- - // Scanline constraint generation - // --------------------------------------------------------------------------- + // The two-pass method's transitivity lemma can fail at float boundaries + // (a rectangle exactly abutting a neighbour after the x pass), leaving + // pairs overlapping in both axes. Enumerate residuals with a sweepline, + // force a direct separation constraint in the cheaper axis, re-solve; + // a few rounds drives the count to zero. + + #cleanupResiduals(): void { + // Empirically the residual count drops to zero within one or two rounds + // (usually zero rounds — the two-pass alone is exact away from boundaries); + // the cap is a generous safety bound that is never approached in practice. + const maxRounds = 16; + for (let round = 0; round < maxRounds; round++) { + if (this.#detectResiduals() === 0) { + return; + } + this.statCleanupRounds += 1; + this.#resolveAxis(true); + this.#resolveAxis(false); + } + } + + /** Enumerate pairs still overlapping in both axes into #resLeft/#resRight. */ + #detectResiduals(): number { + const n = this.#n; + const events = this.#eventOrder; + for (let i = 0; i < n; i++) { + events[i] = i; + } + const lowX = (i: number) => this.#gx[i]! - this.#ghalfW[i]!; + events.subarray(0, n).sort((a, b) => { + const la = lowX(a); + const lb = lowX(b); + if (la < lb) { + return -1; + } + if (la > lb) { + return 1; + } + return a - b; + }); + + let count = 0; + let activeLen = 0; + const active = this.#active; + for (let e = 0; e < n; e++) { + const v = events[e]!; + const vLow = this.#gx[v]! - this.#ghalfW[v]!; + let write = 0; + for (let r = 0; r < activeLen; r++) { + const u = active[r]!; + if (this.#gx[u]! + this.#ghalfW[u]! <= vLow) { + continue; + } + active[write++] = u; + const penX = + this.#ghalfW[u]! + + this.#ghalfW[v]! - + Math.abs(this.#gx[u]! - this.#gx[v]!); + const penY = + this.#ghalfH[u]! + + this.#ghalfH[v]! - + Math.abs(this.#gy[u]! - this.#gy[v]!); + if (penX > MIN_SEP && penY > MIN_SEP) { + if (count + 1 > this.#resCapacity) { + this.#growResiduals(count + 1); + } + this.#resLeft[count] = u; + this.#resRight[count] = v; + count += 1; + } + } + activeLen = write; + active[activeLen++] = v; + } + this.#resCount = count; + return count; + } + + /** Force-separate residual pairs in `isX`, re-solving with direct constraints. */ + #resolveAxis(isX: boolean): void { + const coords = isX ? this.#gx : this.#gy; + this.#slCenter = coords; + this.#generateConstraints(isX); + + const half = isX ? this.#ghalfW : this.#ghalfH; + let forced = 0; + for (let r = 0; r < this.#resCount; r++) { + const a = this.#resLeft[r]!; + const b = this.#resRight[r]!; + const penX = + this.#ghalfW[a]! + + this.#ghalfW[b]! - + Math.abs(this.#gx[a]! - this.#gx[b]!); + const penY = + this.#ghalfH[a]! + + this.#ghalfH[b]! - + Math.abs(this.#gy[a]! - this.#gy[b]!); + const cheaperIsX = penX <= penY; + if (cheaperIsX !== isX) { + continue; + } + const aBeforeB = + coords[a]! < coords[b]! || (coords[a]! === coords[b]! && a < b); + if (half[a]! + half[b]! + MIN_SEP > Math.abs(coords[a]! - coords[b]!)) { + this.#emitConstraint(aBeforeB ? a : b, aBeforeB ? b : a, isX); + forced += 1; + } + } + if (forced === 0) { + return; + } + + this.#buildCsr(); + const n = this.#n; + for (let i = 0; i < n; i++) { + this.#desired[i] = coords[i]!; + } + this.#initBlocks(); + this.#solve(); + for (let i = 0; i < n; i++) { + coords[i] = this.#blockPosn[this.#varBlock[i]!]! + this.#offset[i]!; + } + } + + #growResiduals(needed: number): void { + const newCapacity = Math.max(needed, this.#resCapacity * 2); + const resLeft = new Int32Array(newCapacity); + const resRight = new Int32Array(newCapacity); + resLeft.set(this.#resLeft); + resRight.set(this.#resRight); + this.#resLeft = resLeft; + this.#resRight = resRight; + this.#resCapacity = newCapacity; + } #generateConstraints(isX: boolean): void { const n = this.#n; @@ -287,23 +454,25 @@ export class VpscOverlapRemover { /** Constraints to every scanline neighbour whose cheaper resolution is in x. */ #findXNeighbours(v: number): void { let u = this.#treapSuccessor(v); + let steps = 0; while (u !== -1) { const ox = this.#overlapX(u, v); if (ox <= 0 || ox <= this.#overlapY(u, v)) { this.#emitConstraint(v, u, true); } - if (ox <= 0) { + if (ox <= 0 || ++steps >= MAX_SCAN_NEIGHBOURS) { break; } u = this.#treapSuccessor(u); } u = this.#treapPredecessor(v); + steps = 0; while (u !== -1) { const ox = this.#overlapX(u, v); if (ox <= 0 || ox <= this.#overlapY(u, v)) { this.#emitConstraint(u, v, true); } - if (ox <= 0) { + if (ox <= 0 || ++steps >= MAX_SCAN_NEIGHBOURS) { break; } u = this.#treapPredecessor(u); @@ -322,10 +491,6 @@ export class VpscOverlapRemover { } } - // --------------------------------------------------------------------------- - // Scanline treap (ordered by (#slCenter, index), max-heap on hashed priority) - // --------------------------------------------------------------------------- - #slLess(a: number, b: number): boolean { const ca = this.#slCenter[a]!; const cb = this.#slCenter[b]!; @@ -368,9 +533,9 @@ export class VpscOverlapRemover { const priority = this.#slPriority; while ( this.#slParent[v] !== -1 && - priority[v]! > priority[this.#slParent[v]!]! + priority[v]! > priority[this.#slParent[v]]! ) { - const up = this.#slParent[v]!; + const up = this.#slParent[v]; if (this.#slLeft[up] === v) { this.#rotateRight(up); } else { @@ -477,10 +642,6 @@ export class VpscOverlapRemover { return parent; } - // --------------------------------------------------------------------------- - // CSR adjacency - // --------------------------------------------------------------------------- - #buildCsr(): void { const n = this.#n; const numCon = this.#numCon; @@ -513,10 +674,6 @@ export class VpscOverlapRemover { } } - // --------------------------------------------------------------------------- - // Block bookkeeping - // --------------------------------------------------------------------------- - #initBlocks(): void { const n = this.#n; this.#blocksLen = 0; @@ -588,21 +745,27 @@ export class VpscOverlapRemover { ); } - // --------------------------------------------------------------------------- - // Solver (mirrors WebCola Solver.solve / satisfy / mostViolated + Blocks.split) - // --------------------------------------------------------------------------- - #solve(): void { this.#satisfy(); let cost = this.#cost(); let lastCost = Number.MAX_VALUE; let guard = 0; const maxOuter = 4 * this.#n + 16; - while (Math.abs(lastCost - cost) > COST_TOLERANCE && guard++ < maxOuter) { + // Relative tolerance: absolute epsilon is scale-dependent and over-iterates + // at large layout scales. Feasibility (zero overlap) is guaranteed by each + // #satisfy(); the outer loop only refines toward min-displacement. + while ( + lastCost - cost > COST_TOLERANCE * (1 + cost) && + guard++ < maxOuter + ) { this.#satisfy(); lastCost = cost; cost = this.#cost(); } + this.statOuter += guard; + if (this.#numCon > this.statMaxNumCon) { + this.statMaxNumCon = this.#numCon; + } } #cost(): number { @@ -619,6 +782,7 @@ export class VpscOverlapRemover { this.#splitBlocks(); const maxInner = 8 * (this.#numCon + this.#n) + 64; let guard = 0; + this.statSatisfyInner += 1; while (guard++ < maxInner) { const v = this.#mostViolated(); if ( @@ -702,7 +866,7 @@ export class VpscOverlapRemover { #splitBlocks(): void { const snapshotLen = this.#blocksLen; - const snapshot = this.#stack; + const snapshot = this.#blockSnapshot; for (let i = 0; i < snapshotLen; i++) { snapshot[i] = this.#blocksList[i]!; } @@ -730,6 +894,7 @@ export class VpscOverlapRemover { /** Rebuild the connected component of active constraints reachable from `start`. */ #createSplitBlock(start: number): void { const b = this.#allocBlock(); + this.#insertBlock(b); this.#blockHead[b] = -1; this.#blockCount[b] = 0; this.#blockSumDesired[b] = 0; @@ -748,8 +913,8 @@ export class VpscOverlapRemover { } this.#blockPosn[b] = - (this.#blockSumDesired[b]! - this.#blockSumOffset[b]!) / - this.#blockCount[b]!; + (this.#blockSumDesired[b] - this.#blockSumOffset[b]) / + this.#blockCount[b]; } #visitSplitNeighbours( @@ -897,10 +1062,6 @@ export class VpscOverlapRemover { return minCon; } - // --------------------------------------------------------------------------- - // Allocation / growth - // --------------------------------------------------------------------------- - #ensureNodeCapacity(n: number): void { if (n > this.#capacity) { this.#allocateNode(n); @@ -924,6 +1085,7 @@ export class VpscOverlapRemover { this.#blockListIndex = new Int32Array(blockCapacity); this.#blockAlive = new Uint8Array(blockCapacity); this.#blocksList = new Int32Array(blockCapacity); + this.#blockSnapshot = new Int32Array(blockCapacity); this.#freeBlocks = new Int32Array(blockCapacity); this.#outOffsets = new Int32Array(capacity + 1); @@ -948,6 +1110,10 @@ export class VpscOverlapRemover { this.#eventOrder = new Int32Array(2 * capacity); this.#eventPos = new Float64Array(2 * capacity); + + this.#gx = new Float64Array(capacity); + this.#gy = new Float64Array(capacity); + this.#active = new Int32Array(capacity); } #allocateConstraints(conCapacity: number): void { diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts index 950b3693a7a..748202f684b 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts @@ -6,6 +6,7 @@ import { FLAT_RECORD_BYTES, FlatGraphBuffer, } from "../buffers/position-buffer"; +import { countOverlaps } from "./overlap-relax"; import { createStressLayout } from "./stress-layout"; import type { @@ -24,6 +25,20 @@ function positionsOf(layout: LayoutSimulation): [number, number][] { return layout.nodes.map((node) => [node.x ?? 0, node.y ?? 0]); } +/** Count disk-overlapping pairs in a settled layout (centres closer than r_i + r_j + padding). */ +function overlapCountOf(layout: LayoutSimulation, padding = 0): number { + const count = layout.nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of layout.nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + return countOverlaps({ x, y, radii, count, padding }); +} + function makeNodes(count: number): ForceNode[] { const nodes: ForceNode[] = []; for (let idx = 0; idx < count; idx++) { @@ -209,6 +224,65 @@ describe("createStressLayout", () => { expect(view[4 * slots + 1]).toBeCloseTo(node4.y ?? 0, 3); }); + it("settles a dense hub (1 + 120 leaves) with zero disk overlaps", () => { + const count = 121; + const nodes = makeNodes(count); + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < count; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + const layout = layoutOf(nodes, edges); + settle(layout); + + expect(layout.isSettled).toBe(true); + // The soft fused term alone leaves residual overlaps around the hub; the terminal + // VPSC projection must drive them to exactly zero. + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + it("also removes overlaps after a warm absorb (terminal projection re-runs)", () => { + const nodes = makeNodes(40); + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < 40; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + const layout = layoutOf(nodes, edges); + settle(layout); + expect(overlapCountOf(layout, 0)).toBe(0); + + const newNodes: ForceNode[] = []; + const grownEdges: ForceEdge[] = [...edges]; + for (let id = 40; id < 90; id++) { + newNodes.push({ id: String(id), x: 0, y: 0, radius: 4 }); + grownEdges.push({ source: "0", target: String(id), weight: 1 }); + } + layout.absorb!(newNodes, grownEdges); + settle(layout); + + expect(layout.isSettled).toBe(true); + expect(layout.nodes.length).toBe(90); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + + it("interleaved overlap removal also ends overlap-free", () => { + const count = 121; + const nodes = makeNodes(count); + const edges: ForceEdge[] = []; + for (let leaf = 1; leaf < count; leaf++) { + edges.push({ source: "0", target: String(leaf), weight: 1 }); + } + const layout = createStressLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + { overlapRemovalInterval: 10 }, + ); + settle(layout); + + expect(layout.isSettled).toBe(true); + expect(overlapCountOf(layout, 0)).toBe(0); + }); + it("re-globalises (refreshes Louvain communities) after significant growth", () => { const nodes = makeNodes(6); const edges: ForceEdge[] = [ diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts index 7bc66fa3650..c4884d42796 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts @@ -9,9 +9,15 @@ * {@link SparseStressSolver} runs sparse-stress SGD (Zheng et al.) over the Ortmann * sparse-stress model AND fuses an eta-scaled node-size proximity/overlap term into * the same SGD step (PRISM-style overlap-as-stress; Gansner & Hu 2010), so distance - * fidelity and non-overlap converge jointly instead of fighting a terminal overlap - * pass. The epoch count is adaptive — the solver stops once the layout stops moving - * rather than at a fixed horizon. + * fidelity and non-overlap converge jointly. The epoch count is adaptive — the solver + * stops once the layout stops moving rather than at a fixed horizon. + * + * The fused term is *soft*, so it reaches equilibrium with the stress pull and dense + * hubs still leave residual overlaps. A final {@link VpscOverlapRemover} projection + * ("Fast Node Overlap Removal", Dwyer et al.) therefore snaps the settled layout to a + * guaranteed overlap-free configuration by the smallest displacement — readability wins + * over the last of the edge-length fidelity. Because the soft term has already pre-spread + * the nodes the projection moves them little. * * Pipeline: * 1. Louvain over the link graph → community id per node (for BubbleSets hulls), @@ -19,14 +25,18 @@ * 2. Sparse-stress SGD with the fused overlap term, to adaptive convergence (pivot * terms carry the global structure, edge terms the local structure, the overlap * term keeps same-community dots from piling up around hubs). + * 3. A terminal VPSC overlap-removal projection (each node a square of half-extent + * `radius + overlapPadding/2`), guaranteeing zero disk overlap in the final layout. + * `overlapRemovalInterval` optionally also runs it every K epochs during phase 2. * * Streaming `absorb` continues from the current positions (SGD is init-robust), so - * new nodes settle in beside their neighbours without a cold restart. + * new nodes settle in beside their neighbours without a cold restart, then re-projects. */ import { UndirectedGraph } from "graphology"; import louvain from "graphology-communities-louvain"; import { parkMillerRng } from "../../math/random"; +import { VpscOverlapRemover } from "./overlap-removal"; import { SparseStressSolver } from "./sparse-stress-solver"; import type { FlatGraphBuffer } from "../buffers/position-buffer"; @@ -74,6 +84,12 @@ const CONVERGENCE_EPSILON = 3e-3; */ const OVERLAP_WEIGHT = 4; +/** + * VPSC overlap-removal projection interval (SGD epochs). 0 = terminal-only; + * positive K also projects every K epochs during the stress phase. + */ +const OVERLAP_REMOVAL_INTERVAL = 0; + /** Refresh Louvain once the layout grows by this fraction, so BubbleSets track it (matches FA2). */ const LOUVAIN_REFRESH_GROWTH_FRACTION = 0.3; const LOUVAIN_REFRESH_MIN_NEW_NODES = 24; @@ -85,7 +101,7 @@ interface IndexEdge { readonly weight: number; } -type StressPhase = "stress" | "done"; +type StressPhase = "stress" | "overlap" | "done"; export interface StressLayoutOptions { readonly idealEdgeLength?: number; @@ -101,6 +117,8 @@ export interface StressLayoutOptions { readonly overlapPadding?: number; /** Relaxation weight for the fused overlap term, relative to edge weight. Default 4. */ readonly overlapWeight?: number; + /** VPSC overlap-removal projection interval (0 = terminal-only). */ + readonly overlapRemovalInterval?: number; } class StressLayout implements LayoutSimulation { @@ -121,6 +139,23 @@ class StressLayout implements LayoutSimulation { #status: ForceLayoutStatus; #phase: StressPhase; + /** Exact overlap-removal projector, reused across ticks/absorbs (it self-grows). */ + #overlapRemover: VpscOverlapRemover | null = null; + /** Scratch square half-extents (`radius + overlapPadding/2`), reused across calls. */ + #halfExtents = new Float32Array(0); + /** Last epoch at which the interleaved projection ran (reset per solve). */ + #lastInterleaveEpoch = 0; + + /** Cumulative wall time (ms) spent in the VPSC projection; a bench diagnostic. */ + overlapProjectionMs = 0; + /** Number of VPSC projection calls; a bench diagnostic. */ + overlapProjectionCalls = 0; + // TEMP probe fields + statOuter = 0; + statNumCon = 0; + statCleanup = 0; + statInner = 0; + #absorbedSinceLouvain = 0; #countAtLastLouvain = 0; @@ -140,6 +175,8 @@ class StressLayout implements LayoutSimulation { convergenceEpsilon: options.convergenceEpsilon ?? CONVERGENCE_EPSILON, overlapPadding: options.overlapPadding ?? OVERLAP_PADDING, overlapWeight: options.overlapWeight ?? OVERLAP_WEIGHT, + overlapRemovalInterval: + options.overlapRemovalInterval ?? OVERLAP_REMOVAL_INTERVAL, }; const count = nodes.length; @@ -286,6 +323,7 @@ class StressLayout implements LayoutSimulation { true, ) : null; + this.#lastInterleaveEpoch = 0; this.#phase = count > 0 ? "stress" : "done"; this.#status = count > 0 ? "running" : "settled"; this.#writePositions(); @@ -305,17 +343,65 @@ class StressLayout implements LayoutSimulation { return true; } - /** One unit of work: advance the solver. Overlap resolution is fused into its SGD. */ + /** One unit of work: stress phase ticks the solver (with optional + * interleaved projections), then a terminal overlap projection. */ #advance(): void { - if (this.#phase !== "stress") { + if (this.#phase === "stress") { + const result = this.#solver!.tick({ maxWork: SEED_TICK_WORK }); + const interval = this.#options.overlapRemovalInterval; + if ( + interval > 0 && + result.epoch >= this.#lastInterleaveEpoch + interval + ) { + this.#removeOverlaps(); + this.#lastInterleaveEpoch = result.epoch; + } + if (result.done) { + this.#phase = "overlap"; + } return; } - const result = this.#solver!.tick({ maxWork: SEED_TICK_WORK }); - if (result.done) { + if (this.#phase === "overlap") { + this.#removeOverlaps(); this.#phase = "done"; } } + /** + * Project `#x`/`#y` to the nearest disk-overlap-free configuration via VPSC, modelling + * each node as a square of half-extent `radius + overlapPadding/2` — so separated square + * centres sit ≥ `r_i + r_j + overlapPadding` apart and no disks overlap. Reuses the + * projector (which self-grows) and the half-extent scratch across calls. + */ + #removeOverlaps(): void { + const count = this.#nodes.length; + if (count <= 1) { + return; + } + this.#overlapRemover ??= new VpscOverlapRemover(count); + if (this.#halfExtents.length < count) { + this.#halfExtents = new Float32Array(count); + } + const halfPadding = this.#options.overlapPadding / 2; + for (let index = 0; index < count; index++) { + this.#halfExtents[index] = this.#radii[index]! + halfPadding; + } + const projectionStart = performance.now(); + this.#overlapRemover.removeOverlaps( + this.#x, + this.#y, + this.#halfExtents, + this.#halfExtents, + count, + ); + this.overlapProjectionMs += performance.now() - projectionStart; + this.overlapProjectionCalls += 1; + this.statOuter = this.#overlapRemover.statOuter; + this.statNumCon = this.#overlapRemover.statMaxNumCon; + this.statCleanup = this.#overlapRemover.statCleanupRounds; + this.statInner = this.#overlapRemover.statSatisfyInner; + } + #buildRadii(): Float32Array { const count = this.#nodes.length; const radii = new Float32Array(count); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts index f6e77043d1c..7ef47b22d49 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-vs-fa2.bench.ts @@ -194,18 +194,18 @@ function pad(text: string, width: number, alignRight = true): string { return alignRight ? text.padStart(width) : text.padEnd(width); } +/** Cumulative VPSC projection time (ms), if this layout tracks it (stress engine only). */ +function projectionMsOf(layout: LayoutSimulation): number | undefined { + return (layout as { overlapProjectionMs?: number }).overlapProjectionMs; +} + function comparisonReport(caseShape: GraphShape): string { const { nodes, edges } = buildForceGraph(caseShape); - const columns = [8, 12, 12, 10, 10, 10]; + const columns = [8, 12, 10, 12, 10, 10, 10]; const row = (cells: readonly string[]): string => - [ - pad(cells[0]!, columns[0]!, false), - pad(cells[1]!, columns[1]!), - pad(cells[2]!, columns[2]!), - pad(cells[3]!, columns[3]!), - pad(cells[4]!, columns[4]!), - pad(cells[5]!, columns[5]!), - ].join(" "); + cells + .map((cell, index) => pad(cell, columns[index]!, index !== 0)) + .join(" "); const lines: string[] = []; lines.push( @@ -215,6 +215,7 @@ function comparisonReport(caseShape: GraphShape): string { const header = row([ "engine", "wall ms", + "projMs", "edgeStress", "edgeCV", "overlaps", @@ -228,10 +229,12 @@ function comparisonReport(caseShape: GraphShape): string { const runs = engine.name === "FA2" ? 2 : 3; const { minMs, layout } = solveMin(engine.make, nodes, edges, runs); const quality = measureQuality(layout.nodes, edges); + const projectionMs = projectionMsOf(layout); lines.push( row([ engine.name, minMs.toFixed(1), + projectionMs === undefined ? "-" : projectionMs.toFixed(2), quality.edgeStress.toFixed(4), quality.edgeCv.toFixed(4), String(quality.overlaps), diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts index 82f53b420fa..5d2d93550f1 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/property.ts @@ -129,13 +129,16 @@ export class PropertyStore { this.#entityNumericValues = []; } - /** Register property display titles. Additive; later batches never overwrite. */ - registerTitles(entries: readonly PropertySchemaEntry[]): void { + /** Register property display titles. Returns true if any new title was added. */ + registerTitles(entries: readonly PropertySchemaEntry[]): boolean { + let added = false; for (const { baseUrl, title } of entries) { if (title && !this.#titles.has(baseUrl)) { this.#titles.set(baseUrl, title); + added = true; } } + return added; } /** Human title for a base URL, falling back to a slug-derived title. */ diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts index d0ab13ef602..012f510c1f6 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/store/type-registry.ts @@ -85,9 +85,10 @@ export class TypeRegistry { /** * Register type schemas. Two passes: first intern everything (so parent - * refs resolve), then build ancestor closures. + * refs resolve), then build ancestor closures. Returns whether any + * schema was newly registered. */ - registerAll(schemas: readonly TypeSchemaEntry[]): void { + registerAll(schemas: readonly TypeSchemaEntry[]): boolean { const newlyRegistered: TypeId[] = []; for (const schema of schemas) { @@ -119,6 +120,8 @@ export class TypeRegistry { this.#assignColorSlots(newlyRegistered); this.#computeClosures(); } + + return newlyRegistered.length > 0; } #assignColorSlots(newlyRegistered: readonly TypeId[]): void { From 35fbfc5214846bc277578c69d425e89be4456416 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:02:42 +0200 Subject: [PATCH 46/78] feat: code review --- .../render/gpu/icon-atlas.ts | 70 +-- .../shared/graph-visualizer-2/render/scene.ts | 66 +- .../graph-visualizer-2/render/type-icons.ts | 25 +- .../render/worker-connection.ts | 34 +- .../worker/bench-fixtures.ts | 46 ++ .../worker/core/graph-worker.ts | 36 +- .../worker/layout/_perf.test.ts | 139 ---- .../worker/layout/_probe.test.ts | 39 -- .../worker/layout/forbid-vs-vpsc.bench.ts | 410 ++++++++++++ .../worker/layout/forbid.test.ts | 150 +++++ .../worker/layout/forbid.ts | 591 ++++++++++++++++++ .../worker/layout/overlap-removal.ts | 16 - .../worker/layout/stress-layout.perf.test.ts | 92 +++ .../worker/layout/stress-layout.test.ts | 9 +- .../worker/layout/stress-layout.ts | 140 ++--- 15 files changed, 1442 insertions(+), 421 deletions(-) delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts delete mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid-vs-vpsc.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.test.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.perf.test.ts diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts index f53c9782c6b..dd9c22e07db 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/gpu/icon-atlas.ts @@ -1,26 +1,17 @@ /** - * Async, incrementally-built rasterised icon atlas for the flat-tier type-icon IconLayer. + * Incrementally-built rasterised icon atlas. * - * A single 2D canvas is a grid of fixed-size cells; each unique icon key is rasterised ONCE - * into a free cell and recorded in the mapping ({key -> {x, y, width, height}}). The IconLayer - * samples cells from this canvas (uploaded to a GPU texture) by key. {@link version} bumps on - * ANY change (a new cell, a finished async raster, a canvas grow) so the layer's getIcon - * updateTrigger re-evaluates and a freshly-ready icon appears. + * A 2D canvas grid of fixed-size cells; each unique icon key is rasterised once. + * {@link version} bumps on any change (new cell, finished async raster, canvas grow). * - * Icon formats mirror {@link "@hashintel/design-system".EntityOrTypeIcon}: - * - a URL (`http(s)://` or `/`) is a monochrome SVG drawn as a WHITE silhouette (recoloured - * via `source-in`), loaded ASYNCHRONOUSLY -- it is pending (not in the mapping, {@link has} - * false) until the image resolves, so the layer simply draws no icon for it meanwhile, then - * it appears on the version bump. A load error drops the key (it just never shows an icon). - * - any other short string is an emoji, drawn SYNCHRONOUSLY with `fillText`. + * Icon formats: + * - URL (`http(s)://` or `/`): monochrome SVG drawn as a white silhouette + * (recoloured via `source-in`), loaded asynchronously. Pending until resolved. + * - Other string: emoji, drawn synchronously with `fillText`. * - * The cells are pre-coloured (white silhouettes / full-colour emoji), so the layer draws with - * `getColor: [255,255,255,255]`. - * - * The atlas also owns the GPU texture lifecycle: deck v9's `IconLayer.iconAtlas` wants a luma - * `Texture` (it does NOT accept a canvas), and the texture must be built with the SAME device - * the layer renders on, so {@link getTexture} lazily (re)builds it from the canvas keyed on - * {@link version} -- the atlas knows precisely when its pixels changed. + * Cells are pre-coloured (white silhouettes / full-colour emoji). The atlas owns + * the GPU texture lifecycle: {@link getTexture} lazily rebuilds from the canvas + * when the version or device changes. */ import type { Device, Texture } from "@luma.gl/core"; @@ -36,9 +27,8 @@ export interface AtlasCell { const CELL_SIZE = 64; /** - * Cells per row. FIXED for the atlas's life so a slot's (col, row) -- and thus its mapping - * rectangle -- never changes; growth only ADDS ROWS (a taller canvas), so the existing pixels copy - * across with one `drawImage(previous, 0, 0)` and every recorded cell stays valid. + * Cells per row. Fixed for the atlas's life; growth only adds rows, so + * existing slots keep their (col, row) and pixels copy with one blit. */ const COLUMNS = 8; @@ -65,18 +55,14 @@ function resolveIconUrl(icon: string): string { : icon; } -/** - * One claimed cell. `cellIndex` is the stable row-major slot (assigned in claim order and never - * reused), so a canvas grow recomputes every rectangle from these indices unambiguously. `ready` - * flips true once the pixels are drawn; only ready cells are exposed in the deck `iconMapping`. - */ +/** A claimed cell. `cellIndex` is a stable row-major slot (never reused). */ interface AtlasEntry { readonly cellIndex: number; ready: boolean; } export class IconAtlas { - /** Bumped on ANY change (new cell, finished async raster, canvas grow). */ + /** Bumped on any change (new cell, finished async raster, canvas grow). */ #version = 0; /** The backing canvas; a grid of {@link CELL_SIZE} cells, uploaded to the GPU by the layer. */ #canvas: HTMLCanvasElement; @@ -118,12 +104,7 @@ export class IconAtlas { return this.#canvas; } - /** - * The atlas as a GPU texture on `device`, rebuilt from the canvas whenever {@link version} or - * the device changed since the last build (a grown canvas changes both the pixels and the - * texture dimensions). Returns the cached texture otherwise. The destroyed-on-rebuild old - * texture is fine: the IconLayer reads `iconAtlas` fresh each render via the version trigger. - */ + /** GPU texture, rebuilt from the canvas when version or device changes. */ getTexture(device: Device): Texture { if ( this.#texture === undefined || @@ -143,8 +124,7 @@ export class IconAtlas { return this.#texture; } - /** The deck.gl `iconMapping`: every READY key -> its cell rectangle. Cached by version so the - * returned identity is stable between atlas changes. */ + /** deck.gl `iconMapping` for ready keys. Cached by version (stable identity). */ getMapping(): Record { if (this.#mapping !== undefined && this.#mappingVersion === this.#version) { return this.#mapping; @@ -165,11 +145,7 @@ export class IconAtlas { return this.#entries.get(key)?.ready === true; } - /** - * Ensure each key is rasterised (or in flight). Emoji keys raster synchronously here; URL keys - * claim a cell, mark pending, and raster on image load. Already-claimed keys (ready OR pending) - * are skipped, so each unique icon is rasterised exactly once. - */ + /** Ensure each key is rasterised (or in flight). Already-claimed keys are skipped. */ ensureIcons(keys: readonly string[]): void { for (const key of keys) { if (key.length === 0 || this.#entries.has(key)) { @@ -201,11 +177,7 @@ export class IconAtlas { return slot; } - /** - * Add rows: re-allocate a TALLER canvas (columns fixed) and copy the existing pixels with a - * single blit. Because columns are unchanged, every slot keeps its (col, row), so the recorded - * cell rectangles stay valid and only the canvas height -- and thus the texture -- grows. - */ + /** Add rows: taller canvas, single blit copy, existing cell rectangles stay valid. */ #grow(): void { const previous = this.#canvas; this.#rows += ROW_GROWTH; @@ -267,11 +239,7 @@ export class IconAtlas { image.src = resolveIconUrl(key); } - /** - * Draw `image` fitted into `cell` (preserving aspect), then recolour the drawn pixels to a - * solid WHITE silhouette via `source-in` -- HASH URL icons are monochrome SVGs shown as masks, - * so only the alpha shape matters and the layer tints it via getColor. - */ + /** Draw `image` fitted into `cell`, then recolour to a white silhouette via `source-in`. */ #drawSilhouette(image: HTMLImageElement, cell: AtlasCell): void { const ctx = this.#ctx; ctx.save(); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts index bde02214e6a..9e13b7a1429 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/scene.ts @@ -272,10 +272,8 @@ export interface EntityLabel { } /** - * The cached set of which flat-tier dots carry an always-on (hub) label, with the resolved text + entity. - * Rebuilt ONLY on a zoom / structure change ({@link Scene["#rebuildEntityLabelData"]} -- the perf - * rule); each frame the Scene projects each datum's LIVE SAB position and emits the on-screen ones - * as {@link EntityLabel}s. `recordIndex` is the render index in the layout's SAB as it was scanned. + * Cached hub-label eligibility set. Rebuilt on zoom/structure change; + * each frame projects SAB positions and emits on-screen labels. */ interface EntityLabelDatum { readonly layoutId: ClusterId; @@ -340,32 +338,14 @@ export class Scene { viewStateZoom(INITIAL_VIEW_STATE), ); - /** - * The always-on entity-label SET + resolved text. Rebuilt ONLY on a zoom or structure change - * (the perf rule -- never an O(n) scan or a label resolve on a pan / position frame); the - * TextLayer then reads each dot's LIVE position from the SAB per-datum on `#positionTick`. - */ + /** Hub-label eligibility + resolved text. Rebuilt on zoom/structure change. */ #entityLabelData: EntityLabelDatum[] = []; - /** - * Per-render-index type-icon atlas key (or null), for the flat tier, in the layout SAB's record - * order as it was last scanned. Rebuilt ONLY on a structure change / resolver change (the perf - * rule -- the only O(dots) icon-resolution scan), exactly like {@link #entityLabelData}; the - * IconLayer indexes it by render index. {@link #entityIconNamesVersion} bumps with it to drive - * the layer's getIcon trigger. - */ + /** Flat-tier per-render-index icon atlas key. Rebuilt on structure/resolver change. */ #entityIconNames: (string | null)[] = []; - /** - * Per open hierarchical leaf (keyed by `layoutId`), the per-local-index type-icon atlas key (or - * null), in the leaf SAB's record order. The hierarchical counterpart of {@link #entityIconNames}; - * scanned in the same {@link Scene["#rebuildEntityIconData"]} pass and sharing its version. - */ + /** Hierarchical per-leaf icon atlas keys. Shares version with {@link #entityIconNames}. */ #leafIconNames = new Map(); #entityIconNamesVersion = 0; - /** - * The rasterised type-icon atlas (emoji + URL silhouettes) feeding the type-icon IconLayers (flat - * tier + per-leaf hierarchical). Async rasters bump its version + call back into - * {@link #refreshDataLayers} so a finished icon appears. - */ + /** Rasterised type-icon atlas. Async rasters bump version and re-push layers. */ readonly #iconAtlas: IconAtlas; /** The Deck GPU device, captured once it initialises; the atlas texture is built on it. */ #device: Device | undefined; @@ -737,10 +717,8 @@ export class Scene { } else if (labelColorBucketChanged) { this.#pushLayers(); } - // HTML overlays (selection / highway / cluster cards + hub labels) are positioned by PROJECTED - // screen coords, so they must re-project on EVERY camera move -- pan included -- or they freeze - // in place while the canvas slides under them. Cheap: labels are rAF-coalesced and the cards - // update only a GPU transform (their bodies are memoized), so this is safe per drag frame. + // HTML overlays use projected screen coords, so they must re-project on + // every camera move (pan included) or they freeze under the sliding canvas. this.#emitSelection(); this.#emitHighwayHover(); this.#emitClusterHover(); @@ -1475,12 +1453,7 @@ export class Scene { ); } - /** - * The live world position of a labelled dot, read from the SAB via the SAME byte math the - * selection ring uses ({@link nodeGeometry}). Called per-datum by {@link #emitEntityLabels}; - * returns null for a transiently-missing record (the set is rebuilt next zoom / structure), so - * that label is simply skipped this frame. - */ + /** Live world position of a labelled dot from the SAB. Null if the record is transiently missing. */ #readLabelPosition( datum: EntityLabelDatum, ): readonly [number, number] | null { @@ -1583,12 +1556,9 @@ export class Scene { } /** - * Recompute WHICH dots label + their text. PERF-CRITICAL: this is the only O(dots) scan and - * the only place {@link SceneCallbacks.resolveEntityLabel} runs, and it fires ONLY on a zoom - * or structure change -- NEVER on a pan or a position frame (those reuse the cached set and - * just re-read positions). A dot labels once its on-screen diameter clears {@link - * ENTITY_LABEL_MIN_SCREEN_DIAMETER}. Hierarchical leaves intentionally do not get always-on - * hub labels; the bubble and edge labels already carry that view's orientation. + * Recompute which dots label + their text. O(dots) scan; fires only on + * zoom/structure change (position frames reuse the cached set). A dot labels + * once its screen diameter clears {@link ENTITY_LABEL_MIN_SCREEN_DIAMETER}. */ #rebuildEntityLabelData(): void { const resolveLabel = this.#callbacks.resolveEntityLabel; @@ -1647,15 +1617,9 @@ export class Scene { } /** - * Recompute the per-render-index type-icon atlas KEYS for BOTH tiers (the flat whole-graph SAB - * into {@link #entityIconNames}, and each open leaf's SAB into {@link #leafIconNames}), and ensure - * those icons are rasterised. PERF-CRITICAL and gated EXACTLY like {@link #rebuildEntityLabelData}: - * this is the only O(dots) icon-resolution scan and the only place - * {@link SceneCallbacks.resolveEntityIcon} runs -- it fires ONLY on a structure / resolver change, - * NEVER on a pan or a position frame (those reuse the cached `names` and just re-read SAB - * positions). Every dot gets an entry (its key or null); soft-LOD sizing in the IconLayer hides - * small ones, so there is no zoom gate here (which is also why, unlike labels, a zoom change need - * not rebuild this). + * Recompute per-render-index icon atlas keys for both tiers and ensure they + * are rasterised. O(dots) scan; fires only on structure/resolver change. + * No zoom gate: the IconLayer's soft-LOD sizing handles small dots. */ #rebuildEntityIconData(): void { const resolveIcon = this.#callbacks.resolveEntityIcon; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts index 173fc7e84d6..8deffd23d9a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/type-icons.ts @@ -1,14 +1,7 @@ /** - * TYPE-ICON render: each entity's type icon drawn at its dot centre via a deck.gl `IconLayer`, - * reading the SAME SAB the dots read (binary attributes, stride/offset onto the record fields) -- so - * positions/sizes never drift from the dots and there is no gather. Icons come from {@link IconAtlas} - * (a rasterised atlas of emoji + URL silhouettes); `names` maps a render index to its atlas key (or - * null for none). {@link typeIconLayer} serves the flat tier (one whole-graph SAB, per-node radius); - * {@link leafTypeIconLayers} serves the hierarchical tier (one layer per open leaf, uniform radius). - * - * SOFT LOD: the icon is sized as a fraction of the dot DIAMETER and fades in only once that dot has - * enough screen presence. The only CPU zoom work is a coarse-bucket accessor refresh from Scene, not - * per-pan churn. + * Type-icon render via `IconLayer`, reading the same SAB as the dots + * (binary attributes, stride/offset). Icons fade in once the dot has enough + * screen presence (soft LOD, coarse-bucket accessor refresh). */ import { IconLayer } from "@deck.gl/layers"; @@ -30,10 +23,7 @@ import type { ClusterReference } from "./worker-connection"; import type { Layer } from "@deck.gl/core"; import type { Device } from "@luma.gl/core"; -/** - * Icon diameter as a fraction of the dot DIAMETER, leaving a ring of the dot's type colour visible - * around the icon as padding so the glyph reads as sitting INSIDE the dot, not covering it. - */ +/** Icon diameter as a fraction of the dot diameter, leaving visible type-colour padding. */ const ICON_TO_DOT_DIAMETER = 0.55; const ICON_MIN_SCREEN_DIAMETER = 18; const ICON_FADE_PX = 10; @@ -171,11 +161,8 @@ interface LeafTypeIconLayersParams { } /** - * Hierarchical-tier type icons: one {@link IconLayer} per open leaf, drawn over the leaf's entity - * dots. Each leaf reads its own position SAB (local coords) offset to the leaf centre by the SAME - * modelMatrix the dots use, so the icons track the dots exactly. A leaf's dots share ONE radius, so - * the soft-LOD is all-or-nothing per leaf: below the screen-size bar the leaf's layer is skipped - * entirely (cheaper than a per-dot fade that would never differ within a leaf). + * Hierarchical-tier type icons: one layer per open leaf. Each leaf shares one + * radius, so the soft-LOD is all-or-nothing per leaf (cheaper than a per-dot fade). */ export function leafTypeIconLayers({ structure, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts index b6e6ce7dd65..33079faa2aa 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/render/worker-connection.ts @@ -45,12 +45,7 @@ export interface ClusterReference { /** `[x0, y0, x1, y1, ...]` in the leaf's LOCAL frame. Replaced on non-SAB fallback. */ positions: Float32Array; readonly nodeIds: readonly string[]; - /** - * Present when this is the flat-tier interleaved `FlatGraphBuffer` (positions + - * radii + colours in one buffer, capacity = this). The presentation reads the - * record fields straight off `versionView.buffer` via stride/offset; absent for - * a positions-only entity SAB. - */ + /** Present for the flat-tier interleaved `FlatGraphBuffer`; absent for leaf SABs. */ readonly flatCapacity?: number; } @@ -83,38 +78,21 @@ export interface WorkerHandle { getPositions(): PositionsFrame | undefined; /** Open-leaf entity position SABs, keyed by leaf cluster id. */ getClusters(): Map; - /** - * Subscribe to coalesced structure/position updates. Replays the current - * structure + position immediately so a late subscriber catches up. Returns an - * unsubscribe function. - */ + /** Subscribe to updates. Replays current state immediately; returns unsubscribe. */ subscribe(listener: WorkerListener): () => void; - /** - * Resolve a picked entity dot to its EntityId via the EntityIdx->EntityId map SAB. The - * flat tier reads the stable `entityIdx` off the (reorderable) record; a hierarchical - * leaf (fixed node set) takes it from the leaf's nodeIds. Undefined until the id-map and - * the layout's buffer both exist. - */ + /** Resolve a picked entity dot to its EntityId via the id-map SAB. */ resolveEntityId( layoutId: ClusterId, recordIndex: number, ): EntityId | undefined; - /** Decode an EntityIdx straight to its EntityId via the id-map SAB -- a picked edge already - * has the link's EntityIdx, so it skips the record read {@link resolveEntityId} does. */ + /** Decode an EntityIdx to its EntityId via the id-map SAB. */ entityIdToId(entityIdx: EntityIndex): EntityId | undefined; - /** - * The raw EntityIdx (join key) for a record -- the integer {@link resolveEntityId} decodes. - * Used to query a node's neighbors without re-deriving the buffer layout in the caller. - */ + /** The raw EntityIdx (join key) for a record. */ entityIdxAt( layoutId: ClusterId, recordIndex: number, ): EntityIndex | undefined; - /** - * Locate the wanted entityIdxs within a layout's buffer -> their current render indices, for - * placing highlight neighbors. A hierarchical leaf maps via its (fixed) nodeIds; the flat - * buffer reorders, so its live records are scanned. - */ + /** Map wanted entityIdxs to their current render indices within a layout's buffer. */ locateRecords( layoutId: ClusterId, wanted: ReadonlySet, diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts index 951f8c026b6..d0b4b5510df 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/bench-fixtures.ts @@ -182,6 +182,52 @@ export function buildForceGraph(shape: GraphShape): { return { nodes, edges }; } +/** + * A {@link buildForceGraph} cloud PLUS one super-hub with `hubLeaves` degree-1 leaves, + * initialised near-coincident with the hub. This reproduces the production pathology + * that froze the old terminal VPSC projection for seconds: a single hub's worth of + * leaves cannot be pulled apart by the stress phase's soft overlap term (it reaches + * equilibrium), so a tight near-coincident pile-up reaches the overlap-removal phase. + * The extra node/edge ids continue the cloud's index space. + */ +export function buildForceGraphWithCoincidentHub( + shape: GraphShape, + hubLeaves: number, +): { + readonly nodes: ForceNode[]; + readonly edges: ForceEdge[]; +} { + const { nodes, edges } = buildForceGraph(shape); + const random = mulberry32(shape.seed + 0x5bd1e995); + const hubIndex = shape.nodeCount; + + nodes.push({ + id: String(hubIndex), + x: 0, + y: 0, + radius: radiusForDegree(hubLeaves), + }); + for (let leaf = 0; leaf < hubLeaves; leaf++) { + const leafIndex = hubIndex + 1 + leaf; + // ~40px from the shared centre, mutually overlapping — the real hub geometry. + const angle = random() * Math.PI * 2; + const distance = 40 * random(); + nodes.push({ + id: String(leafIndex), + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + radius: radiusForDegree(1), + }); + edges.push({ + source: String(hubIndex), + target: String(leafIndex), + weight: 1, + }); + } + + return { nodes, edges }; +} + /** Link store and entity index column. Entity indices are the contiguous range `[0, nodeCount)`. */ export function buildCommunityInputs(shape: GraphShape): { readonly entityIdxs: Column; diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts index 7de1d8b716f..41fc6f3b029 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/core/graph-worker.ts @@ -424,10 +424,42 @@ export class GraphWorker { continue; } - const changed = layout.tick(1); - const kind = this.#layoutKind.get(clusterId); + const layoutTickStart = performance.now(); + const changed = layout.tick(1); + const layoutTickMs = performance.now() - layoutTickStart; + + // Per-tick instrumentation for the incremental overlap-removal (FORBID) phase: + // confirms on the user's actual graph that no single tick freezes and that the + // overlap count marches to zero. Debug-gated; the fields are duck-typed so this + // stays agnostic to which layout engine (stress vs FA2) is mounted. + if (this.debug && kind === "entities") { + const diag = layout as Partial<{ + forbidOverlaps: number; + overlapProjectionCalls: number; + maxForbidStepMs: number; + forbidExpansions: number; + edgeCount: number; + }>; + if ( + changed && + typeof diag.overlapProjectionCalls === "number" && + diag.overlapProjectionCalls > 0 + ) { + // eslint-disable-next-line no-console + console.debug( + `[graph-worker][forbid] cluster=${clusterId} ` + + `n=${layout.nodes.length} edges=${diag.edgeCount ?? "?"} ` + + `tickMs=${layoutTickMs.toFixed(2)} ` + + `epochs=${diag.overlapProjectionCalls} ` + + `overlaps=${diag.forbidOverlaps ?? "?"} ` + + `expansions=${diag.forbidExpansions ?? 0} ` + + `maxStepMs=${(diag.maxForbidStepMs ?? 0).toFixed(2)}`, + ); + } + } + if (kind === "entities") { if (changed && clusterId === FLAT_LAYOUT_ID) { // Flat edges are worker-built beziers; emit a frame so they diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts deleted file mode 100644 index efaf2f58c99..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_perf.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { describe, expect, it } from "vitest"; -import { Rectangle, removeOverlaps as colaRemoveOverlaps } from "webcola"; - -import { mulberry32 } from "../../math/random"; -import { VpscOverlapRemover } from "./overlap-removal"; - -interface RectSet { - x: Float32Array; - y: Float32Array; - halfW: Float32Array; - halfH: Float32Array; - count: number; -} - -function coincident(count: number, jitter: number): RectSet { - const rng = mulberry32(7); - const x = new Float32Array(count); - const y = new Float32Array(count); - const halfW = new Float32Array(count).fill(6); - const halfH = new Float32Array(count).fill(6); - for (let i = 0; i < count; i++) { - x[i] = (rng() - 0.5) * jitter; - y[i] = (rng() - 0.5) * jitter; - } - return { x, y, halfW, halfH, count }; -} - -function ring(count: number, radius: number, half: number): RectSet { - const x = new Float32Array(count); - const y = new Float32Array(count); - const halfW = new Float32Array(count).fill(half); - const halfH = new Float32Array(count).fill(half); - for (let i = 0; i < count; i++) { - const angle = (i / count) * Math.PI * 2; - x[i] = Math.cos(angle) * radius; - y[i] = Math.sin(angle) * radius; - } - return { x, y, halfW, halfH, count }; -} - -function hubRing(count: number, radius: number): RectSet { - const set = ring(count - 1, radius, 5); - const x = new Float32Array(count); - const y = new Float32Array(count); - const halfW = new Float32Array(count); - const halfH = new Float32Array(count); - x[0] = 0; - y[0] = 0; - halfW[0] = 10; - halfH[0] = 10; - for (let i = 1; i < count; i++) { - x[i] = set.x[i - 1]!; - y[i] = set.y[i - 1]!; - halfW[i] = 5; - halfH[i] = 5; - } - return { x, y, halfW, halfH, count }; -} - -function clustered(count: number): RectSet { - const rng = mulberry32(11); - const x = new Float32Array(count); - const y = new Float32Array(count); - const halfW = new Float32Array(count).fill(6); - const halfH = new Float32Array(count).fill(6); - const clusterCount = Math.max(1, Math.round(count / 150)); - for (let i = 0; i < count; i++) { - const cluster = i % clusterCount; - const cx = (cluster % 40) * 400; - const cy = Math.floor(cluster / 40) * 400; - x[i] = cx + (rng() - 0.5) * 20; - y[i] = cy + (rng() - 0.5) * 20; - } - return { x, y, halfW, halfH, count }; -} - -function overlappingPairs(set: RectSet, eps = 1e-2): number { - const { x, y, halfW, halfH, count } = set; - let pairs = 0; - for (let a = 0; a < count; a++) { - for (let b = a + 1; b < count; b++) { - const penX = halfW[a]! + halfW[b]! - Math.abs(x[a]! - x[b]!); - const penY = halfH[a]! + halfH[b]! - Math.abs(y[a]! - y[b]!); - if (penX > eps && penY > eps) { - pairs += 1; - } - } - } - return pairs; -} - -function timeMine(set: RectSet): string { - const remover = new VpscOverlapRemover(set.count); - const start = performance.now(); - remover.removeOverlaps(set.x, set.y, set.halfW, set.halfH, set.count); - const ms = performance.now() - start; - return `mine ms=${ms.toFixed(0)} overlaps=${overlappingPairs(set)} outer=${remover.statOuter} inner=${remover.statSatisfyInner} numCon=${remover.statMaxNumCon} cleanup=${remover.statCleanupRounds}`; -} - -function timeCola(set: RectSet): string { - const rects = new Array(set.count); - for (let i = 0; i < set.count; i++) { - rects[i] = new Rectangle( - set.x[i]! - set.halfW[i]!, - set.x[i]! + set.halfW[i]!, - set.y[i]! - set.halfH[i]!, - set.y[i]! + set.halfH[i]!, - ); - } - const start = performance.now(); - colaRemoveOverlaps(rects); - const ms = performance.now() - start; - return `cola ms=${ms.toFixed(0)}`; -} - -describe("perf probe", () => { - it("adversarial", () => { - const lines: string[] = []; - const cases: [string, RectSet][] = [ - ["150-exact", coincident(150, 0)], - ["1000-exact", coincident(1000, 0)], - ["1000-jit1", coincident(1000, 1)], - ["2000-exact", coincident(2000, 0)], - ["5000-exact", coincident(5000, 0)], - ["5000-clustered", clustered(5000)], - ["150-hubRing40", hubRing(150, 40)], - ]; - for (const [name, set] of cases) { - const before = overlappingPairs(set); - if (name.endsWith("cola")) { - lines.push(`${name}: ${timeCola(set)}`); - } else { - lines.push(`${name}(pre=${before}): ${timeMine(set)}`); - } - } - expect(lines.join("\n")).toBe(""); - }, 60000); -}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts deleted file mode 100644 index f9035582ae7..00000000000 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/_probe.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import { describe, expect, it } from "vitest"; - -import { buildForceGraph } from "../bench-fixtures"; -import { FlatGraphBuffer } from "../buffers/position-buffer"; -import { createStressLayout } from "./stress-layout"; - -import type { GraphShape } from "../bench-fixtures"; - -describe("probe 5000 projection", () => { - it("stats", () => { - const shape: GraphShape = { - nodeCount: 5000, - linkCount: 13000, - typeCount: 1, - hubCount: 125, - rootFraction: 1, - seed: 303, - }; - const { nodes, edges } = buildForceGraph(shape); - const buffer = new FlatGraphBuffer(nodes.length); - const layout = createStressLayout(nodes, edges, buffer); - for (let step = 0; step < 10_000_000 && !layout.isSettled; step++) { - if (!layout.tick(1)) { - break; - } - } - const diag = layout as unknown as { - overlapProjectionMs: number; - statOuter: number; - statNumCon: number; - statCleanup: number; - statInner: number; - }; - expect( - `projMs=${diag.overlapProjectionMs.toFixed(1)} outer=${diag.statOuter} inner=${diag.statInner} numCon=${diag.statNumCon} cleanup=${diag.statCleanup}`, - ).toBe(""); - }, 60000); -}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid-vs-vpsc.bench.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid-vs-vpsc.bench.ts new file mode 100644 index 00000000000..02626dd43b6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid-vs-vpsc.bench.ts @@ -0,0 +1,410 @@ +/* eslint-disable no-console -- committed A/B harness whose whole purpose is to PRINT a + FA2-vs-VPSC-vs-FORBID comparison on the production-realistic coincident-hub fixture. */ +/** + * The A/B that motivated the pivot away from VPSC. On the production-realistic fixture + * (a large cloud PLUS one super-hub of 150 near-coincident leaves — the shape that froze + * the worker), it drives three overlap-removal strategies at the worker's 1 ms tick + * cadence and prints, per size: + * + * - engine : FA2 (no overlap guarantee) / stress+VPSC (old) / stress+FORBID (new) + * - wallMs : construct → overlap-free wall time + * - maxTickMs : the LARGEST single synchronous tick — the frozen-frame metric. The + * old terminal VPSC projection is ONE unbounded call, so its maxTick is + * the whole projection; FORBID chunks across ticks so its maxTick is one + * bounded SGD epoch batch. + * - overlaps : strict disk overlaps in the final layout (must be 0 for VPSC/FORBID) + * - edgeStress : RMS deviation of edge length from ideal (lower = better fidelity) + * + * The stress+VPSC row runs the SAME sparse-stress phase as stress+FORBID (identical solver + * config), then a single `VpscOverlapRemover.removeOverlaps` call, reproducing the old + * synchronous mega-call. `overlap-removal.ts` is kept solely for this comparison. + * + * Run (from apps/hash-frontend): + * node_modules/.bin/vitest bench --run \ + * src/pages/shared/graph-visualizer-2/worker/layout/forbid-vs-vpsc.bench.ts + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { bench, describe } from "vitest"; + +import { buildForceGraphWithCoincidentHub } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { createCommunityLayout } from "./community-layout"; +import { ForbidOverlapSolver } from "./forbid"; +import { countOverlaps } from "./overlap-relax"; +import { VpscOverlapRemover } from "./overlap-removal"; +import { SparseStressSolver } from "./sparse-stress-solver"; +import { createStressLayout } from "./stress-layout"; + +import type { GraphShape } from "../bench-fixtures"; +import type { + ForceEdge, + ForceNode, + LayoutSimulation, +} from "./force-simulation"; + +/** Mirror of the stress engine's private layout constants (kept in sync with stress-layout.ts). */ +const IDEAL = 60; +const OVERLAP_PADDING = 8; +const OVERLAP_WEIGHT = 4; +const SEED_JITTER = 0.01; +const STRESS_TICK_WORK = 16_384; +const STRESS_MAX_EPOCHS = 60; +const STRESS_MIN_EPOCHS = 8; +const CONVERGENCE_EPSILON = 3e-3; + +/** Production worker cadence: one simulation step per animation frame gets ~1 ms. */ +const TICK_BUDGET_MS = 1; +const HUB_LEAVES = 150; + +/** Cloud + coincident-hub fixtures at the three sizes the freeze was reported around. */ +const AB_SIZES = [1_000, 3_000, 5_000] as const; + +function hubShape(cloudCount: number): GraphShape { + return { + nodeCount: cloudCount, + linkCount: Math.round(cloudCount * 2.6), + typeCount: 1, + hubCount: Math.max(4, Math.round(cloudCount / 40)), + rootFraction: 1, + seed: 7_000 + cloudCount, + }; +} + +function cloneNodes(nodes: readonly ForceNode[]): ForceNode[] { + return nodes.map((node) => ({ ...node })); +} + +/** RMS deviation of edge length from the ideal hop length (lower ⇒ better distance fidelity). */ +function edgeStressOf( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], +): number { + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + let sumSq = 0; + let counted = 0; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + const sourceNode = nodes[source]!; + const targetNode = nodes[target]!; + const length = Math.hypot( + (targetNode.x ?? 0) - (sourceNode.x ?? 0), + (targetNode.y ?? 0) - (sourceNode.y ?? 0), + ); + const ratio = length / IDEAL - 1; + sumSq += ratio * ratio; + counted += 1; + } + return counted > 0 ? Math.sqrt(sumSq / counted) : 0; +} + +function overlapsOf(nodes: readonly ForceNode[]): number { + const count = nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + return countOverlaps({ x, y, radii, count, padding: 0 }); +} + +interface Measurement { + readonly wallMs: number; + readonly maxTickMs: number; + readonly overlaps: number; + readonly edgeStress: number; +} + +/** Drive a whole engine to `settled` at the 1 ms cadence, timing each tick. */ +function driveEngine( + make: ( + nodes: ForceNode[], + edges: ForceEdge[], + buffer: FlatGraphBuffer, + ) => LayoutSimulation, + nodes: readonly ForceNode[], + edges: ForceEdge[], +): Measurement { + const runNodes = cloneNodes(nodes); + const buffer = new FlatGraphBuffer(Math.max(1, runNodes.length)); + let maxTickMs = 0; + let wallMs = 0; + const layout = make(runNodes, edges, buffer); + for (let step = 0; step < 500_000 && !layout.isSettled; step++) { + const start = performance.now(); + const moved = layout.tick(TICK_BUDGET_MS); + const tickMs = performance.now() - start; + wallMs += tickMs; + maxTickMs = Math.max(maxTickMs, tickMs); + if (!moved) { + break; + } + } + return { + wallMs, + maxTickMs, + overlaps: overlapsOf(layout.nodes), + edgeStress: edgeStressOf(layout.nodes, edges), + }; +} + +/** Build the stress SGD solver over the fixture with the engine's production knobs. */ +function buildStressSolver( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], + x: Float32Array, + y: Float32Array, + radii: Float32Array, +): SparseStressSolver { + const idToIndex = new Map(); + for (const [index, node] of nodes.entries()) { + idToIndex.set(node.id, index); + } + const src: number[] = []; + const dst: number[] = []; + for (const edge of edges) { + const sourceId = + typeof edge.source === "string" ? edge.source : edge.source.id; + const targetId = + typeof edge.target === "string" ? edge.target : edge.target.id; + const source = idToIndex.get(sourceId); + const target = idToIndex.get(targetId); + if (source === undefined || target === undefined || source === target) { + continue; + } + src.push(source); + dst.push(target); + } + return new SparseStressSolver( + { + n: nodes.length, + src: Uint32Array.from(src), + dst: Uint32Array.from(dst), + x, + y, + radii, + }, + { + idealEdgeLength: IDEAL, + randomSeed: 1, + jitter: SEED_JITTER, + maxEpochs: STRESS_MAX_EPOCHS, + minEpochs: STRESS_MIN_EPOCHS, + convergenceEpsilon: CONVERGENCE_EPSILON, + overlapPadding: OVERLAP_PADDING, + overlapWeight: OVERLAP_WEIGHT, + keepInitialPositions: false, + packComponents: true, + returnPivotDistances: false, + }, + ); +} + +/** Run the chunked stress phase (timing each tick), returning the settled coordinates. */ +function runStressPhase( + nodes: readonly ForceNode[], + edges: readonly ForceEdge[], +): { + readonly x: Float32Array; + readonly y: Float32Array; + readonly radii: Float32Array; + readonly maxTickMs: number; + readonly wallMs: number; +} { + const count = nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + const solver = buildStressSolver(nodes, edges, x, y, radii); + let maxTickMs = 0; + let wallMs = 0; + let done = false; + while (!done) { + const start = performance.now(); + const result = solver.tick({ maxWork: STRESS_TICK_WORK }); + const tickMs = performance.now() - start; + wallMs += tickMs; + maxTickMs = Math.max(maxTickMs, tickMs); + done = result.done; + } + return { x, y, radii, maxTickMs, wallMs }; +} + +/** OLD path: stress phase (chunked) then a single synchronous VPSC projection. */ +function measureStressVpsc( + nodes: readonly ForceNode[], + edges: ForceEdge[], +): Measurement { + const count = nodes.length; + const stress = runStressPhase(nodes, edges); + const x = Float32Array.from(stress.x); + const y = Float32Array.from(stress.y); + const halfW = new Float32Array(count); + const halfH = new Float32Array(count); + for (let index = 0; index < count; index++) { + const half = stress.radii[index]! + OVERLAP_PADDING / 2; + halfW[index] = half; + halfH[index] = half; + } + + const remover = new VpscOverlapRemover(count); + const start = performance.now(); + remover.removeOverlaps(x, y, halfW, halfH, count); + const vpscCallMs = performance.now() - start; + + const settled = cloneNodes(nodes); + for (let index = 0; index < count; index++) { + settled[index]!.x = x[index]!; + settled[index]!.y = y[index]!; + } + return { + wallMs: stress.wallMs + vpscCallMs, + maxTickMs: Math.max(stress.maxTickMs, vpscCallMs), + overlaps: overlapsOf(settled), + edgeStress: edgeStressOf(settled, edges), + }; +} + +/** NEW path: stress phase (chunked) then FORBID chunked across 1 ms ticks. */ +function measureStressForbid( + nodes: readonly ForceNode[], + edges: ForceEdge[], +): Measurement { + const count = nodes.length; + const stress = runStressPhase(nodes, edges); + const x = Float32Array.from(stress.x); + const y = Float32Array.from(stress.y); + + const forbid = new ForbidOverlapSolver(count); + forbid.reset(x, y, stress.radii, count, { margin: OVERLAP_PADDING, seed: 1 }); + let maxTickMs = stress.maxTickMs; + let wallMs = stress.wallMs; + while (!forbid.done) { + const start = performance.now(); + // Batch epochs into one ~1 ms tick (mirrors the worker looping advance() per tick). + let stepped = forbid.step(); + while (!stepped.done && performance.now() - start < TICK_BUDGET_MS) { + stepped = forbid.step(); + } + const tickMs = performance.now() - start; + wallMs += tickMs; + maxTickMs = Math.max(maxTickMs, tickMs); + } + + const settled = cloneNodes(nodes); + for (let index = 0; index < count; index++) { + settled[index]!.x = x[index]!; + settled[index]!.y = y[index]!; + } + return { + wallMs, + maxTickMs, + overlaps: overlapsOf(settled), + edgeStress: edgeStressOf(settled, edges), + }; +} + +function pad(text: string, width: number): string { + return text.padStart(width); +} + +function abReport(cloudCount: number): string { + const { nodes, edges } = buildForceGraphWithCoincidentHub( + hubShape(cloudCount), + HUB_LEAVES, + ); + const rows: { readonly name: string; readonly m: Measurement }[] = [ + { + name: "FA2", + m: driveEngine( + (runNodes, runEdges, buffer) => + createCommunityLayout(runNodes, runEdges, buffer), + nodes, + edges, + ), + }, + { name: "stress+VPSC", m: measureStressVpsc(nodes, edges) }, + { name: "stress+FORBID", m: measureStressForbid(nodes, edges) }, + ]; + + const columns = [14, 12, 12, 10, 12]; + const line = (cells: readonly string[]): string => + cells.map((cell, index) => pad(cell, columns[index]!)).join(" "); + const lines: string[] = []; + lines.push( + `\n=== FA2 vs VPSC vs FORBID: ${nodes.length} nodes / ${edges.length} edges ` + + `(cloud ${cloudCount} + ${HUB_LEAVES}-leaf coincident hub) ===`, + ); + const header = line([ + "engine", + "wallMs", + "maxTickMs", + "overlaps", + "edgeStress", + ]); + lines.push(header); + lines.push("-".repeat(header.length)); + for (const { name, m } of rows) { + lines.push( + line([ + name, + m.wallMs.toFixed(1), + m.maxTickMs.toFixed(2), + String(m.overlaps), + m.edgeStress.toFixed(4), + ]), + ); + } + return lines.join("\n"); +} + +for (const cloudCount of AB_SIZES) { + console.log(abReport(cloudCount)); +} + +/** Timing cross-check: whole stress+FORBID engine to overlap-free at the two smaller sizes. */ +const BENCH_OPTIONS = { + time: 0, + iterations: 2, + warmupTime: 0, + warmupIterations: 0, +} as const; + +for (const cloudCount of AB_SIZES.slice(0, 2)) { + describe(`stress+FORBID to overlap-free (${cloudCount} + hub)`, () => { + const { nodes, edges } = buildForceGraphWithCoincidentHub( + hubShape(cloudCount), + HUB_LEAVES, + ); + bench( + "stress+FORBID", + () => { + driveEngine( + (runNodes, runEdges, buffer) => + createStressLayout(runNodes, runEdges, buffer), + nodes, + edges, + ); + }, + BENCH_OPTIONS, + ); + }); +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.test.ts new file mode 100644 index 00000000000..ed1bd914fb0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.test.ts @@ -0,0 +1,150 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { mulberry32 } from "../../math/random"; +import { ForbidOverlapSolver } from "./forbid"; + +interface DiskSet { + x: Float32Array; + y: Float32Array; + radii: Float32Array; + count: number; +} + +/** All disks stacked within `jitter` of the origin — the pathological hub case. */ +function coincident(count: number, radius = 6, jitter = 0): DiskSet { + const rng = mulberry32(count * 7 + 1); + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (let index = 0; index < count; index++) { + x[index] = (rng() - 0.5) * jitter; + y[index] = (rng() - 0.5) * jitter; + radii[index] = radius; + } + return { x, y, radii, count }; +} + +/** A realistic mix: a spread-out cloud plus one tight near-coincident hub. */ +function cloudWithHub( + cloudCount: number, + hubCount: number, + seed: number, +): DiskSet { + const rng = mulberry32(seed); + const count = cloudCount + hubCount; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + const span = Math.sqrt(cloudCount) * 40; + for (let index = 0; index < cloudCount; index++) { + x[index] = (rng() - 0.5) * span; + y[index] = (rng() - 0.5) * span; + radii[index] = 3 + rng() * 6; + } + // The hub: hubCount leaves all ~40px from a shared centre, mutually overlapping. + const hubX = (rng() - 0.5) * span; + const hubY = (rng() - 0.5) * span; + for (let index = cloudCount; index < count; index++) { + const angle = rng() * Math.PI * 2; + x[index] = hubX + Math.cos(angle) * 40 * rng(); + y[index] = hubY + Math.sin(angle) * 40 * rng(); + radii[index] = 4; + } + return { x, y, radii, count }; +} + +/** Direct O(n²) disk-overlap count (centres closer than `r_i + r_j`). */ +function diskOverlaps(set: DiskSet, eps = 1e-3): number { + const { x, y, radii, count } = set; + let pairs = 0; + for (let a = 0; a < count; a++) { + for (let b = a + 1; b < count; b++) { + const minDist = radii[a]! + radii[b]!; + const dx = x[a]! - x[b]!; + const dy = y[a]! - y[b]!; + if (Math.sqrt(dx * dx + dy * dy) < minDist - eps) { + pairs += 1; + } + } + } + return pairs; +} + +const MARGIN = 8; + +describe("ForbidOverlapSolver", () => { + it("removes overlaps from a random cloud", () => { + const set = cloudWithHub(500, 0, 3); + const solver = new ForbidOverlapSolver(set.count); + solver.reset(set.x, set.y, set.radii, set.count, { margin: MARGIN }); + const result = solver.runToCompletion(); + + expect(result.done).toBe(true); + expect(result.overlaps).toBe(0); + expect(diskOverlaps(set)).toBe(0); + }); + + it.each([120, 150, 300, 1000])( + "separates a %i-disk exactly-coincident pile-up", + (count) => { + const set = coincident(count); + const solver = new ForbidOverlapSolver(count); + solver.reset(set.x, set.y, set.radii, count, { margin: MARGIN }); + const result = solver.runToCompletion(); + + expect(result.done).toBe(true); + expect(result.overlaps).toBe(0); + expect(diskOverlaps(set)).toBe(0); + }, + ); + + it("separates a cloud with a tight near-coincident hub", () => { + const set = cloudWithHub(1000, 150, 11); + const solver = new ForbidOverlapSolver(set.count); + solver.reset(set.x, set.y, set.radii, set.count, { margin: MARGIN }); + const result = solver.runToCompletion(); + + expect(result.done).toBe(true); + expect(diskOverlaps(set)).toBe(0); + }); + + it("is deterministic (same input ⇒ identical output)", () => { + const first = coincident(200, 5, 2); + const second = coincident(200, 5, 2); + + const solverA = new ForbidOverlapSolver(200); + solverA.reset(first.x, first.y, first.radii, 200, { margin: MARGIN }); + solverA.runToCompletion(); + + const solverB = new ForbidOverlapSolver(200); + solverB.reset(second.x, second.y, second.radii, 200, { margin: MARGIN }); + solverB.runToCompletion(); + + expect(Array.from(first.x)).toEqual(Array.from(second.x)); + expect(Array.from(first.y)).toEqual(Array.from(second.y)); + }); + + it("keeps each epoch cheap on a 5000-node cloud with a coincident hub", () => { + const set = cloudWithHub(4850, 150, 23); + const solver = new ForbidOverlapSolver(set.count); + solver.reset(set.x, set.y, set.radii, set.count, { margin: MARGIN }); + + let worstEpochMs = 0; + let epochs = 0; + while (!solver.done) { + const start = performance.now(); + solver.step(); + worstEpochMs = Math.max(worstEpochMs, performance.now() - start); + epochs += 1; + } + + // eslint-disable-next-line no-console + console.log( + `[forbid] n=${set.count} epochs=${epochs} expansions=${solver.expansions} worstEpochMs=${worstEpochMs.toFixed(2)}`, + ); + expect(diskOverlaps(set)).toBe(0); + // No single epoch (⇒ no single tick's inner step) may approach a frozen frame. + expect(worstEpochMs).toBeLessThan(50); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.ts new file mode 100644 index 00000000000..7f25313de5c --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/forbid.ts @@ -0,0 +1,591 @@ +/* + * FORBID: Fast Overlap Removal By stochastic gradIent Descent — an incremental, + * chunked, grid-accelerated overlap remover for the stress layout's terminal phase. + * + * References: + * - Loann Giovannangeli, Frederic Lalanne, Romain Giot, Romain Bourqui, + * "FORBID: Fast Overlap Removal By stochastic gradIent Descent for + * Graph Drawing" (GD 2022). https://arxiv.org/abs/2208.10334 + * Extended: "Guaranteed Visibility ... Overlap Removal by SGD with(out) + * Shape Awareness" (IEEE TVCG 2024). + * - Pairwise stress-SGD update with per-pair relaxation cap and exponential + * annealing: Zheng, Pawar, Goodman, "Graph Drawing by Stochastic Gradient + * Descent" (2018). https://arxiv.org/pdf/1710.04626 + * + * Why FORBID here: it is the overlap-removal sibling of our sparse-stress engine — + * it optimizes a stress objective with the same SGD move, so it plugs into the + * existing tick/`maxWork` chunking. Unlike the terminal VPSC projection (one + * unbounded synchronous mega-call that froze the worker for seconds on a dense + * near-coincident hub) this runs a BOUNDED number of SGD epochs per tick, writes + * positions every tick so it animates, and only reaches `done` once the layout is + * verifiably overlap-free. + * + * How the paper's pieces map onto this incremental implementation: + * - Stress with overlap floors: each epoch pushes every currently-overlapping + * pair (found via the grid) toward the target gap `r_i + r_j + margin` with the + * SGD half-step move. Non-overlapping pairs contribute nothing (zero gradient), + * so untouched regions of the layout do not move at all. + * - Shape preservation: instead of the paper's global-scale binary search (which + * inflates the WHOLE layout to fix one dense cluster, and does not chunk or + * animate well), a per-node anchor pulls each node toward its settled position + * and DECAYS to zero over `ANCHOR_DECAY_EPOCHS`. While the anchor is live it + * keeps the global shape; as it releases, dense clusters expand locally exactly + * as much as they must. This is the incremental realisation of "minimise + * displacement from the input subject to no overlap". + * - Scaling guarantee: FORBID's existence proof is that scaling all centres up far + * enough is always overlap-free. We keep that as a stall fallback — if overlaps + * plateau after the anchor has released, we scale positions about their centroid, + * which strictly grows every pairwise distance and therefore reaches an + * overlap-free configuration in finitely many steps. + * - Coincident handling: identical/near-identical points have a zero (or unstable) + * separation gradient — the exact reason a 150-leaf hub detonates geometric + * solvers. A deterministic, index-seeded micro-jitter is applied once up front to + * break ties, and coincident pairs separate along a hash-derived direction. + * + * Determinism: jitter and coincident directions are hash-derived from node indices, + * nodes are bucketed and scanned in index order, and each unordered pair is visited + * once, so a seeded layout stays reproducible. + * + * Allocation discipline: all scratch (grid, reference positions) lives in reused + * typed arrays that only grow, so repeated runs (streaming absorb) do not churn GC. + */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-bitwise */ +/* eslint-disable id-length */ + +const EPS = 1e-6; +const TAU = Math.PI * 2; + +/** Per-pass fraction of each overlap corrected (Gauss-Seidel style half-step). */ +const OVERLAP_STRENGTH = 0.85; +/** Initial anchor pull toward the settled layout (shape preservation). */ +const ANCHOR_MAX = 0.5; +/** Epochs over which the anchor ramps linearly to zero (then pure separation). */ +const ANCHOR_DECAY_EPOCHS = 24; +/** Consecutive non-improving epochs (after the anchor releases) before scaling. */ +const STALL_EPOCHS = 10; +/** Minimum scale-up applied to a jammed cluster on stall (never a no-op). */ +const MIN_EXPAND_FACTOR = 1.1; +/** Maximum single-step scale-up, so one expansion can never explode the layout. */ +const MAX_EXPAND_FACTOR = 3; +/** + * Target area utilisation when sizing a jammed cluster's scale-to-fit expansion. + * Disks of side `2r+margin` tile at ~0.9 density; aiming below that leaves slack so + * one expansion clears the jam instead of nibbling at it over many rounds. + */ +const PACKING_UTILISATION = 0.55; +/** Hard safety cap on epochs; chunking means this is never one frozen tick. */ +const MAX_EPOCHS = 5000; +/** Micro-jitter amplitude (world units) applied once to break coincidence. */ +const JITTER_AMPLITUDE = 1e-2; + +/** Bijective-ish 32-bit hash for deterministic jitter and separation directions. */ +function hashU32(value: number): number { + let x = value >>> 0; + x ^= x >>> 16; + x = Math.imul(x, 0x7feb352d); + x ^= x >>> 15; + x = Math.imul(x, 0x846ca68b); + x ^= x >>> 16; + return x >>> 0; +} + +const hash01 = (value: number): number => hashU32(value) / 0x1_0000_0000; + +/** Deterministic separation direction (radians) for coincident node pairs. */ +const coincidentAngle = (i: number, j: number): number => + hash01((Math.imul(i + 1, 0x9e3779b1) ^ Math.imul(j + 1, 0x85ebca6b)) >>> 0) * + TAU; + +/** Spatial-hash a signed integer cell coordinate pair into a table slot. */ +const cellHash = (cx: number, cy: number): number => + (Math.imul(cx, 0x9e3779b1) ^ Math.imul(cy, 0x85ebca6b)) >>> 0; + +export interface ForbidResetOptions { + /** Extra gap enforced beyond `r_i + r_j` (world units). Must be > 0 so disks strictly separate. */ + readonly margin: number; + /** Deterministic seed for jitter / tie-breaking. Default 1. */ + readonly seed?: number; +} + +export interface ForbidStepResult { + readonly done: boolean; + readonly epoch: number; + /** Overlapping pairs remaining at the START of this epoch (0 ⇒ separated). */ + readonly overlaps: number; + /** Largest single-node displacement this epoch (world units). */ + readonly maxMove: number; +} + +/** + * Incremental overlap remover. Drive it with {@link step} once per layout tick (each + * call runs exactly one bounded SGD epoch and rewrites `x`/`y` in place) until + * {@link done}. Reuse one instance across runs/absorbs — the scratch buffers grow but + * are never reallocated per epoch. + */ +export class ForbidOverlapSolver { + #capacity = 0; + #tableMask = 0; + + #n = 0; + #margin = 0; + #seed = 1; + #maxRadius = 0; + #cellSize = 1; + #invCell = 1; + + #epoch = 0; + #done = false; + #overlaps = 0; + #maxMove = 0; + + // Stall tracking for the scaling guarantee. + #bestOverlaps = Number.MAX_SAFE_INTEGER; + #epochsSinceImprovement = 0; + /** Number of stall-triggered global scale-ups this run (diagnostics/tests). */ + expansions = 0; + + // Caller buffers (mutated in place); not owned. + #x: Float32Array = new Float32Array(0); + #y: Float32Array = new Float32Array(0); + #radii: ArrayLike = new Float32Array(0); + + // Owned, reused scratch. + #refX = new Float32Array(0); + #refY = new Float32Array(0); + #cellX = new Int32Array(0); + #cellY = new Int32Array(0); + #head = new Int32Array(0); + #next = new Int32Array(0); + // 1 for nodes that overlapped during the last epoch (the jammed set to un-stick). + #overlapFlag = new Uint8Array(0); + // Union-find + per-cluster accumulators, used only on stall to size expansions. + #ufParent = new Int32Array(0); + #clusterX = new Float64Array(0); + #clusterY = new Float64Array(0); + #clusterSpreadSq = new Float64Array(0); + #clusterAreaSq = new Float64Array(0); + #clusterCount = new Int32Array(0); + + constructor(capacity: number) { + this.#ensureCapacity(Math.max(1, capacity | 0)); + } + + get done(): boolean { + return this.#done; + } + + get epoch(): number { + return this.#epoch; + } + + get overlaps(): number { + return this.#overlaps; + } + + #ensureCapacity(n: number): void { + if (n <= this.#capacity) { + return; + } + const capacity = Math.max(n, this.#capacity * 2, 16); + this.#refX = new Float32Array(capacity); + this.#refY = new Float32Array(capacity); + this.#cellX = new Int32Array(capacity); + this.#cellY = new Int32Array(capacity); + this.#next = new Int32Array(capacity); + this.#overlapFlag = new Uint8Array(capacity); + this.#ufParent = new Int32Array(capacity); + this.#clusterX = new Float64Array(capacity); + this.#clusterY = new Float64Array(capacity); + this.#clusterSpreadSq = new Float64Array(capacity); + this.#clusterAreaSq = new Float64Array(capacity); + this.#clusterCount = new Int32Array(capacity); + // Power-of-two table >= 2n keeps buckets short; mask indexes it. + let tableSize = 1; + while (tableSize < capacity * 2) { + tableSize <<= 1; + } + this.#head = new Int32Array(tableSize); + this.#tableMask = tableSize - 1; + this.#capacity = capacity; + } + + /** + * Begin a new overlap-removal run over `x`/`y`/`radii` (the first `n` entries are + * used and `x`/`y` are mutated in place). Captures the current positions as the + * shape-preservation reference and applies the deterministic coincidence jitter. + */ + reset( + x: Float32Array, + y: Float32Array, + radii: ArrayLike, + n: number, + { margin, seed = 1 }: ForbidResetOptions, + ): void { + this.#ensureCapacity(Math.max(1, n)); + this.#x = x; + this.#y = y; + this.#radii = radii; + this.#n = n; + this.#margin = Math.max(EPS, margin); + this.#seed = seed >>> 0; + + let maxRadius = 0; + for (let i = 0; i < n; i++) { + const r = radii[i]!; + if (r > maxRadius) { + maxRadius = r; + } + } + this.#maxRadius = maxRadius; + // A cell holds any pair that can overlap: centres within 2·maxRadius + margin. + this.#cellSize = Math.max(EPS, 2 * maxRadius + this.#margin); + this.#invCell = 1 / this.#cellSize; + + // Deterministic micro-jitter breaks exact/near coincidence up front, then the + // (jittered) positions become the shape-preservation reference. + for (let i = 0; i < n; i++) { + x[i]! += + (hash01((i ^ (this.#seed * 0x9e3779b1)) >>> 0) - 0.5) * + JITTER_AMPLITUDE; + y[i]! += + (hash01(((i + 0x27d4eb2d) ^ this.#seed) >>> 0) - 0.5) * + JITTER_AMPLITUDE; + this.#refX[i] = x[i]!; + this.#refY[i] = y[i]!; + } + + this.#epoch = 0; + this.#done = n <= 1; + this.#overlaps = 0; + this.#maxMove = 0; + this.#bestOverlaps = Number.MAX_SAFE_INTEGER; + this.#epochsSinceImprovement = 0; + this.expansions = 0; + } + + /** Rebuild the linked-list spatial hash from the current positions. */ + #rebuildGrid(): void { + const n = this.#n; + const head = this.#head; + head.fill(-1); + const invCell = this.#invCell; + for (let i = 0; i < n; i++) { + const cx = Math.floor(this.#x[i]! * invCell); + const cy = Math.floor(this.#y[i]! * invCell); + this.#cellX[i] = cx; + this.#cellY[i] = cy; + const slot = cellHash(cx, cy) & this.#tableMask; + this.#next[i] = head[slot]!; + head[slot] = i; + } + } + + #anchorAlpha(): number { + if (this.#epoch >= ANCHOR_DECAY_EPOCHS) { + return 0; + } + return ANCHOR_MAX * (1 - this.#epoch / ANCHOR_DECAY_EPOCHS); + } + + /** Union-find root with path halving over the jammed set. */ + #find(i: number): number { + const parent = this.#ufParent; + let root = i; + while (parent[root] !== root) { + parent[root] = parent[parent[root]!]!; + root = parent[root]!; + } + return root; + } + + /** + * FORBID's overlap-free guarantee, applied PER JAMMED CLUSTER with a scale-to-fit + * factor. A metastable dense packing (where the Gauss-Seidel pushes cancel out and + * separation stalls) is guaranteed to loosen under scaling, but scaling only the + * cluster that is stuck — sized from its own area demand — clears the jam in one + * step while leaving the rest of the layout (and other, separate clusters) exactly + * where they are. This is what lets a single dense hub inflate locally instead of + * blowing up the whole drawing, which is the reason we run SGD rather than the + * paper's single global-scale search. Only fires once the anchor has released. + */ + #expandJammed(): void { + const n = this.#n; + const flag = this.#overlapFlag; + const x = this.#x; + const y = this.#y; + const parent = this.#ufParent; + + // Connected components of mutually-overlapping nodes (rebuild the grid so the + // union reflects the positions AFTER this epoch's moves). + for (let i = 0; i < n; i++) { + if (flag[i]) { + parent[i] = i; + } + } + this.#rebuildGrid(); + const head = this.#head; + const next = this.#next; + const cellX = this.#cellX; + const cellY = this.#cellY; + const mask = this.#tableMask; + const radii = this.#radii; + const margin = this.#margin; + for (let a = 0; a < n; a++) { + if (!flag[a]) { + continue; + } + const ra = radii[a]!; + const baseCellX = cellX[a]!; + const baseCellY = cellY[a]!; + for (let ox = -1; ox <= 1; ox++) { + for (let oy = -1; oy <= 1; oy++) { + const qx = baseCellX + ox; + const qy = baseCellY + oy; + let b = head[(cellHash(qx, qy) & mask) >>> 0]!; + while (b !== -1) { + if (b <= a || cellX[b] !== qx || cellY[b] !== qy || !flag[b]) { + b = next[b]!; + continue; + } + const target = ra + radii[b]! + margin; + const dx = x[b]! - x[a]!; + const dy = y[b]! - y[a]!; + if (dx * dx + dy * dy < target * target) { + const ra2 = this.#find(a); + const rb2 = this.#find(b); + if (ra2 !== rb2) { + parent[ra2] = rb2; + } + } + b = next[b]!; + } + } + } + } + + // Accumulate centroid + area demand per cluster root. + for (let i = 0; i < n; i++) { + if (flag[i]) { + this.#clusterX[i] = 0; + this.#clusterY[i] = 0; + this.#clusterSpreadSq[i] = 0; + this.#clusterAreaSq[i] = 0; + this.#clusterCount[i] = 0; + } + } + for (let i = 0; i < n; i++) { + if (flag[i]) { + const r = this.#find(i); + this.#clusterX[r]! += x[i]!; + this.#clusterY[r]! += y[i]!; + const side = 2 * radii[i]! + margin; + this.#clusterAreaSq[r]! += side * side; + this.#clusterCount[r]! += 1; + } + } + for (let i = 0; i < n; i++) { + if (flag[i] && this.#find(i) === i) { + const count = this.#clusterCount[i]!; + this.#clusterX[i]! /= count; + this.#clusterY[i]! /= count; + } + } + for (let i = 0; i < n; i++) { + if (flag[i]) { + const r = this.#find(i); + const dx = x[i]! - this.#clusterX[r]!; + const dy = y[i]! - this.#clusterY[r]!; + this.#clusterSpreadSq[r]! += dx * dx + dy * dy; + } + } + + // Scale each cluster about its centroid by the ratio of the radius its disks + // demand to its current radius of gyration (clamped so no single step explodes). + for (let i = 0; i < n; i++) { + if (!flag[i]) { + continue; + } + const r = this.#find(i); + const count = this.#clusterCount[r]!; + if (count < 2) { + continue; + } + const currentRadius = Math.sqrt((2 * this.#clusterSpreadSq[r]!) / count); + const neededRadius = Math.sqrt( + this.#clusterAreaSq[r]! / (Math.PI * PACKING_UTILISATION), + ); + let factor = neededRadius / Math.max(EPS, currentRadius); + if (factor < MIN_EXPAND_FACTOR) { + factor = MIN_EXPAND_FACTOR; + } else if (factor > MAX_EXPAND_FACTOR) { + factor = MAX_EXPAND_FACTOR; + } + x[i] = this.#clusterX[r]! + (x[i]! - this.#clusterX[r]!) * factor; + y[i] = this.#clusterY[r]! + (y[i]! - this.#clusterY[r]!) * factor; + } + } + + /** + * One SGD epoch: pull toward the (decaying) shape anchor, then push every + * overlapping pair apart toward `r_i + r_j + margin`. Returns the overlapping-pair + * count seen at the start of the epoch and the largest node displacement. + */ + #runEpoch(): void { + const n = this.#n; + const x = this.#x; + const y = this.#y; + const radii = this.#radii; + const margin = this.#margin; + + const anchorAlpha = this.#anchorAlpha(); + if (anchorAlpha > 0) { + for (let i = 0; i < n; i++) { + x[i]! += (this.#refX[i]! - x[i]!) * anchorAlpha; + y[i]! += (this.#refY[i]! - y[i]!) * anchorAlpha; + } + } + + this.#rebuildGrid(); + + const head = this.#head; + const next = this.#next; + const cellX = this.#cellX; + const cellY = this.#cellY; + const mask = this.#tableMask; + const flag = this.#overlapFlag; + flag.fill(0, 0, n); + + let overlaps = 0; + let maxShift = 0; + + for (let a = 0; a < n; a++) { + const ra = radii[a]!; + const baseCellX = cellX[a]!; + const baseCellY = cellY[a]!; + + for (let ox = -1; ox <= 1; ox++) { + for (let oy = -1; oy <= 1; oy++) { + const qx = baseCellX + ox; + const qy = baseCellY + oy; + let b = head[(cellHash(qx, qy) & mask) >>> 0]!; + while (b !== -1) { + // Exact-cell filter both dedupes hash collisions and guarantees each + // unordered pair is visited exactly once (b only matches one of the 9). + if (b <= a || cellX[b] !== qx || cellY[b] !== qy) { + b = next[b]!; + continue; + } + + const target = ra + radii[b]! + margin; + let dx = x[b]! - x[a]!; + let dy = y[b]! - y[a]!; + const distSq = dx * dx + dy * dy; + if (distSq >= target * target) { + b = next[b]!; + continue; + } + + overlaps += 1; + flag[a] = 1; + flag[b] = 1; + let dist = Math.sqrt(distSq); + if (dist < EPS) { + const angle = coincidentAngle(a, b); + dx = Math.cos(angle); + dy = Math.sin(angle); + dist = EPS; + } else { + dx /= dist; + dy /= dist; + } + + const shift = (target - dist) * 0.5 * OVERLAP_STRENGTH; + const sx = dx * shift; + const sy = dy * shift; + x[a]! -= sx; + y[a]! -= sy; + x[b]! += sx; + y[b]! += sy; + if (shift > maxShift) { + maxShift = shift; + } + + b = next[b]!; + } + } + } + } + + this.#overlaps = overlaps; + this.#maxMove = maxShift; + } + + /** + * Run exactly one bounded SGD epoch and advance the state machine. Cheap enough + * (O(n + overlapping pairs)) to call once per layout tick without blowing the tick + * budget; the layout ticker loops it within its ms budget. + */ + step(): ForbidStepResult { + if (this.#done || this.#n <= 1) { + this.#done = true; + return { + done: true, + epoch: this.#epoch, + overlaps: this.#overlaps, + maxMove: 0, + }; + } + + this.#runEpoch(); + this.#epoch += 1; + + // Stall detection (only meaningful once the anchor has released): if separation + // has plateaued above zero, apply the scaling guarantee to force progress. + if (this.#overlaps < this.#bestOverlaps) { + this.#bestOverlaps = this.#overlaps; + this.#epochsSinceImprovement = 0; + } else { + this.#epochsSinceImprovement += 1; + } + if ( + this.#overlaps > 0 && + this.#epoch >= ANCHOR_DECAY_EPOCHS && + this.#epochsSinceImprovement >= STALL_EPOCHS + ) { + this.#expandJammed(); + this.expansions += 1; + this.#epochsSinceImprovement = 0; + this.#bestOverlaps = Number.MAX_SAFE_INTEGER; + } + + // Terminate only once the layout is verifiably overlap-free AND the anchor has + // fully released (so it cannot re-introduce overlaps by pulling toward the + // still-overlapping reference), or at the hard safety cap. + const anchorReleased = this.#epoch >= ANCHOR_DECAY_EPOCHS; + if ((this.#overlaps === 0 && anchorReleased) || this.#epoch >= MAX_EPOCHS) { + this.#done = true; + } + + return { + done: this.#done, + epoch: this.#epoch, + overlaps: this.#overlaps, + maxMove: this.#maxMove, + }; + } + + /** Convenience driver for tests/oracle use: run to `done` (or the cap). */ + runToCompletion(): ForbidStepResult { + let result: ForbidStepResult = { + done: this.#done, + epoch: this.#epoch, + overlaps: this.#overlaps, + maxMove: this.#maxMove, + }; + while (!this.#done) { + result = this.step(); + } + return result; + } +} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts index a54436b51ec..aa234df49f3 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/overlap-removal.ts @@ -140,12 +140,6 @@ export class VpscOverlapRemover { #minLm = 0; - // TEMP perf diagnostics - statOuter = 0; - statCleanupRounds = 0; - statMaxNumCon = 0; - statSatisfyInner = 0; - constructor(capacity: number) { this.#allocateNode(Math.max(1, capacity | 0)); this.#allocateConstraints(Math.max(16, capacity | 0)); @@ -173,10 +167,6 @@ export class VpscOverlapRemover { this.#n = n; this.#ghalfW = halfW; this.#ghalfH = halfH; - this.statOuter = 0; - this.statCleanupRounds = 0; - this.statMaxNumCon = 0; - this.statSatisfyInner = 0; for (let i = 0; i < n; i++) { this.#gx[i] = x[i]!; this.#gy[i] = y[i]!; @@ -224,7 +214,6 @@ export class VpscOverlapRemover { if (this.#detectResiduals() === 0) { return; } - this.statCleanupRounds += 1; this.#resolveAxis(true); this.#resolveAxis(false); } @@ -762,10 +751,6 @@ export class VpscOverlapRemover { lastCost = cost; cost = this.#cost(); } - this.statOuter += guard; - if (this.#numCon > this.statMaxNumCon) { - this.statMaxNumCon = this.#numCon; - } } #cost(): number { @@ -782,7 +767,6 @@ export class VpscOverlapRemover { this.#splitBlocks(); const maxInner = 8 * (this.#numCon + this.#n) + 64; let guard = 0; - this.statSatisfyInner += 1; while (guard++ < maxInner) { const v = this.#mostViolated(); if ( diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.perf.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.perf.test.ts new file mode 100644 index 00000000000..263bf38179d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.perf.test.ts @@ -0,0 +1,92 @@ +/** + * Production-realistic perf gate for the stress engine's incremental FORBID phase. + * + * The fixtures mirror the graph that froze the old terminal VPSC projection: a large + * cloud (1k / 3k / 5k) plus one super-hub with 150+ near-coincident degree-1 leaves. + * Driving the layout with the worker's 1 ms tick budget, we assert BOTH the perf gate + * (no single tick blows past a frame — the whole point of chunking FORBID across ticks) + * AND correctness (the settled layout is strictly overlap-free). + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, it } from "vitest"; + +import { buildForceGraphWithCoincidentHub } from "../bench-fixtures"; +import { FlatGraphBuffer } from "../buffers/position-buffer"; +import { countOverlaps } from "./overlap-relax"; +import { createStressLayout } from "./stress-layout"; + +import type { GraphShape } from "../bench-fixtures"; + +/** Largest single tick may not approach a dropped frame; the old VPSC call was ~6.5 s. */ +const MAX_TICK_MS = 100; +const HUB_LEAVES = 150; + +function overlapCountOf(layout: { + nodes: readonly { x?: number; y?: number; radius: number }[]; +}): number { + const count = layout.nodes.length; + const x = new Float32Array(count); + const y = new Float32Array(count); + const radii = new Float32Array(count); + for (const [index, node] of layout.nodes.entries()) { + x[index] = node.x ?? 0; + y[index] = node.y ?? 0; + radii[index] = node.radius; + } + return countOverlaps({ x, y, radii, count, padding: 0 }); +} + +const CLOUD_SIZES = [1_000, 3_000, 5_000] as const; + +describe("stress layout — coincident-hub perf gate", () => { + it.each(CLOUD_SIZES)( + "stays within the tick budget and ends overlap-free (%i-node cloud + coincident hub)", + (cloudCount) => { + const shape: GraphShape = { + nodeCount: cloudCount, + linkCount: Math.round(cloudCount * 2.6), + typeCount: 1, + hubCount: Math.max(4, Math.round(cloudCount / 40)), + rootFraction: 1, + seed: 4_000 + cloudCount, + }; + const { nodes, edges } = buildForceGraphWithCoincidentHub( + shape, + HUB_LEAVES, + ); + const layout = createStressLayout( + nodes, + edges, + new FlatGraphBuffer(nodes.length), + ); + + let maxTickMs = 0; + let totalMs = 0; + let ticks = 0; + // Drive at the production 1 ms cadence; guard is generous but finite. + for (let step = 0; step < 200_000 && !layout.isSettled; step++) { + const start = performance.now(); + const moved = layout.tick(1); + const tickMs = performance.now() - start; + totalMs += tickMs; + maxTickMs = Math.max(maxTickMs, tickMs); + ticks += 1; + if (!moved) { + break; + } + } + + // eslint-disable-next-line no-console + console.log( + `[stress+forbid] n=${nodes.length} edges=${edges.length} ` + + `ticks=${ticks} totalMs=${totalMs.toFixed(1)} ` + + `maxTickMs=${maxTickMs.toFixed(2)}`, + ); + + expect(layout.isSettled).toBe(true); + expect(maxTickMs).toBeLessThan(MAX_TICK_MS); + expect(overlapCountOf(layout)).toBe(0); + }, + 60_000, + ); +}); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts index 748202f684b..3579184a45d 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.test.ts @@ -236,11 +236,11 @@ describe("createStressLayout", () => { expect(layout.isSettled).toBe(true); // The soft fused term alone leaves residual overlaps around the hub; the terminal - // VPSC projection must drive them to exactly zero. + // FORBID phase must drive them to exactly zero. expect(overlapCountOf(layout, 0)).toBe(0); }); - it("also removes overlaps after a warm absorb (terminal projection re-runs)", () => { + it("also removes overlaps after a warm absorb (FORBID re-runs)", () => { const nodes = makeNodes(40); const edges: ForceEdge[] = []; for (let leaf = 1; leaf < 40; leaf++) { @@ -264,8 +264,8 @@ describe("createStressLayout", () => { expect(overlapCountOf(layout, 0)).toBe(0); }); - it("interleaved overlap removal also ends overlap-free", () => { - const count = 121; + it("settles a very dense hub (1 + 250 leaves) overlap-free", () => { + const count = 251; const nodes = makeNodes(count); const edges: ForceEdge[] = []; for (let leaf = 1; leaf < count; leaf++) { @@ -275,7 +275,6 @@ describe("createStressLayout", () => { nodes, edges, new FlatGraphBuffer(nodes.length), - { overlapRemovalInterval: 10 }, ); settle(layout); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts index c4884d42796..b38d13ea897 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.ts @@ -13,11 +13,11 @@ * stops once the layout stops moving rather than at a fixed horizon. * * The fused term is *soft*, so it reaches equilibrium with the stress pull and dense - * hubs still leave residual overlaps. A final {@link VpscOverlapRemover} projection - * ("Fast Node Overlap Removal", Dwyer et al.) therefore snaps the settled layout to a - * guaranteed overlap-free configuration by the smallest displacement — readability wins - * over the last of the edge-length fidelity. Because the soft term has already pre-spread - * the nodes the projection moves them little. + * hubs still leave residual overlaps. An incremental {@link ForbidOverlapSolver} phase + * (FORBID — "Fast Overlap Removal By stochastic gradIent Descent", Giovannangeli et al. + * GD 2022) then removes the residue with the SAME SGD move as the stress phase, running + * a BOUNDED number of epochs per tick so no single tick freezes and the separation + * animates. It only reaches `done` once the layout is verifiably overlap-free. * * Pipeline: * 1. Louvain over the link graph → community id per node (for BubbleSets hulls), @@ -25,18 +25,19 @@ * 2. Sparse-stress SGD with the fused overlap term, to adaptive convergence (pivot * terms carry the global structure, edge terms the local structure, the overlap * term keeps same-community dots from piling up around hubs). - * 3. A terminal VPSC overlap-removal projection (each node a square of half-extent - * `radius + overlapPadding/2`), guaranteeing zero disk overlap in the final layout. - * `overlapRemovalInterval` optionally also runs it every K epochs during phase 2. + * 3. An incremental FORBID overlap-removal phase (grid-detected overlaps pushed to + * `r_i + r_j + overlapPadding`, a decaying anchor preserving the settled shape, + * and a per-cluster scale-to-fit guarantee), chunked across ticks and guaranteeing + * zero disk overlap in the final layout. * * Streaming `absorb` continues from the current positions (SGD is init-robust), so - * new nodes settle in beside their neighbours without a cold restart, then re-projects. + * new nodes settle in beside their neighbours without a cold restart, then re-separates. */ import { UndirectedGraph } from "graphology"; import louvain from "graphology-communities-louvain"; import { parkMillerRng } from "../../math/random"; -import { VpscOverlapRemover } from "./overlap-removal"; +import { ForbidOverlapSolver } from "./forbid"; import { SparseStressSolver } from "./sparse-stress-solver"; import type { FlatGraphBuffer } from "../buffers/position-buffer"; @@ -84,12 +85,6 @@ const CONVERGENCE_EPSILON = 3e-3; */ const OVERLAP_WEIGHT = 4; -/** - * VPSC overlap-removal projection interval (SGD epochs). 0 = terminal-only; - * positive K also projects every K epochs during the stress phase. - */ -const OVERLAP_REMOVAL_INTERVAL = 0; - /** Refresh Louvain once the layout grows by this fraction, so BubbleSets track it (matches FA2). */ const LOUVAIN_REFRESH_GROWTH_FRACTION = 0.3; const LOUVAIN_REFRESH_MIN_NEW_NODES = 24; @@ -117,8 +112,6 @@ export interface StressLayoutOptions { readonly overlapPadding?: number; /** Relaxation weight for the fused overlap term, relative to edge weight. Default 4. */ readonly overlapWeight?: number; - /** VPSC overlap-removal projection interval (0 = terminal-only). */ - readonly overlapRemovalInterval?: number; } class StressLayout implements LayoutSimulation { @@ -139,22 +132,24 @@ class StressLayout implements LayoutSimulation { #status: ForceLayoutStatus; #phase: StressPhase; - /** Exact overlap-removal projector, reused across ticks/absorbs (it self-grows). */ - #overlapRemover: VpscOverlapRemover | null = null; - /** Scratch square half-extents (`radius + overlapPadding/2`), reused across calls. */ - #halfExtents = new Float32Array(0); - /** Last epoch at which the interleaved projection ran (reset per solve). */ - #lastInterleaveEpoch = 0; + /** Incremental FORBID overlap remover, reused across ticks/absorbs (it self-grows). */ + #forbid: ForbidOverlapSolver | null = null; - /** Cumulative wall time (ms) spent in the VPSC projection; a bench diagnostic. */ + /** Cumulative wall time (ms) spent in FORBID steps; a bench/worker diagnostic. */ overlapProjectionMs = 0; - /** Number of VPSC projection calls; a bench diagnostic. */ + /** Number of FORBID epochs run; a bench/worker diagnostic. */ overlapProjectionCalls = 0; - // TEMP probe fields - statOuter = 0; - statNumCon = 0; - statCleanup = 0; - statInner = 0; + /** Worst single FORBID step (ms) — the per-tick budget guard; a bench diagnostic. */ + maxForbidStepMs = 0; + /** Overlapping pairs remaining at the last FORBID epoch; a worker diagnostic. */ + forbidOverlaps = 0; + /** Per-cluster scale-to-fit expansions FORBID has applied; a bench diagnostic. */ + forbidExpansions = 0; + + /** Resolved (deduped) edge count; a worker/bench diagnostic. */ + get edgeCount(): number { + return this.#indexEdges.length; + } #absorbedSinceLouvain = 0; #countAtLastLouvain = 0; @@ -175,8 +170,6 @@ class StressLayout implements LayoutSimulation { convergenceEpsilon: options.convergenceEpsilon ?? CONVERGENCE_EPSILON, overlapPadding: options.overlapPadding ?? OVERLAP_PADDING, overlapWeight: options.overlapWeight ?? OVERLAP_WEIGHT, - overlapRemovalInterval: - options.overlapRemovalInterval ?? OVERLAP_REMOVAL_INTERVAL, }; const count = nodes.length; @@ -323,7 +316,6 @@ class StressLayout implements LayoutSimulation { true, ) : null; - this.#lastInterleaveEpoch = 0; this.#phase = count > 0 ? "stress" : "done"; this.#status = count > 0 ? "running" : "settled"; this.#writePositions(); @@ -343,63 +335,69 @@ class StressLayout implements LayoutSimulation { return true; } - /** One unit of work: stress phase ticks the solver (with optional - * interleaved projections), then a terminal overlap projection. */ + /** + * One unit of work: the stress phase ticks the SGD solver; once it settles, the + * FORBID phase runs ONE bounded overlap-removal epoch per call (so a single tick + * never runs the whole projection — the fix for the multi-second frozen tick). + */ #advance(): void { if (this.#phase === "stress") { const result = this.#solver!.tick({ maxWork: SEED_TICK_WORK }); - const interval = this.#options.overlapRemovalInterval; - if ( - interval > 0 && - result.epoch >= this.#lastInterleaveEpoch + interval - ) { - this.#removeOverlaps(); - this.#lastInterleaveEpoch = result.epoch; - } if (result.done) { + this.#beginForbid(); this.#phase = "overlap"; } return; } if (this.#phase === "overlap") { - this.#removeOverlaps(); - this.#phase = "done"; + this.#stepForbid(); } } /** - * Project `#x`/`#y` to the nearest disk-overlap-free configuration via VPSC, modelling - * each node as a square of half-extent `radius + overlapPadding/2` — so separated square - * centres sit ≥ `r_i + r_j + overlapPadding` apart and no disks overlap. Reuses the - * projector (which self-grows) and the half-extent scratch across calls. + * Enter the FORBID phase: capture the settled layout as the shape reference and + * apply the coincidence jitter. Reuses the solver instance (it self-grows). */ - #removeOverlaps(): void { + #beginForbid(): void { const count = this.#nodes.length; if (count <= 1) { + this.#phase = "done"; return; } - this.#overlapRemover ??= new VpscOverlapRemover(count); - if (this.#halfExtents.length < count) { - this.#halfExtents = new Float32Array(count); - } - const halfPadding = this.#options.overlapPadding / 2; - for (let index = 0; index < count; index++) { - this.#halfExtents[index] = this.#radii[index]! + halfPadding; + this.#forbid ??= new ForbidOverlapSolver(count); + this.#forbid.reset(this.#x, this.#y, this.#radii, count, { + margin: this.#options.overlapPadding, + seed: 1, + }); + this.maxForbidStepMs = 0; + } + + /** + * Run one bounded FORBID epoch over `#x`/`#y` (pushes grid-detected overlaps toward + * `r_i + r_j + overlapPadding`, decaying the shape anchor, per-cluster scale-to-fit + * on stall). Transitions to `done` once the layout is verifiably overlap-free. + */ + #stepForbid(): void { + const forbid = this.#forbid; + if (!forbid) { + this.#phase = "done"; + return; } - const projectionStart = performance.now(); - this.#overlapRemover.removeOverlaps( - this.#x, - this.#y, - this.#halfExtents, - this.#halfExtents, - count, - ); - this.overlapProjectionMs += performance.now() - projectionStart; + const stepStart = performance.now(); + const result = forbid.step(); + const stepMs = performance.now() - stepStart; + + this.overlapProjectionMs += stepMs; this.overlapProjectionCalls += 1; - this.statOuter = this.#overlapRemover.statOuter; - this.statNumCon = this.#overlapRemover.statMaxNumCon; - this.statCleanup = this.#overlapRemover.statCleanupRounds; - this.statInner = this.#overlapRemover.statSatisfyInner; + if (stepMs > this.maxForbidStepMs) { + this.maxForbidStepMs = stepMs; + } + this.forbidOverlaps = result.overlaps; + this.forbidExpansions = forbid.expansions; + + if (result.done) { + this.#phase = "done"; + } } #buildRadii(): Float32Array { From 709e97f4574558276a50e19b14c2420a4e9027ad Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:21:10 +0200 Subject: [PATCH 47/78] feat: code review --- .relayout-contact.png | Bin 0 -> 1652842 bytes .relayout-contact2.png | Bin 0 -> 1144964 bytes .../shared/graph-visualizer-2/PERFORMANCE.md | 13 + .../components/dev-harness-controls-panel.tsx | 35 + .../pages/shared/graph-visualizer-2/config.ts | 19 + .../shared/graph-visualizer-2/dev-harness.tsx | 15 + .../worker/core/graph-worker.ts | 2 +- .../layout/community-degree-terms.bench.ts | 360 +++++++ .../layout/sparse-stress-solver.test.ts | 222 +++++ .../worker/layout/sparse-stress-solver.ts | 908 ++++++++++++++++-- .../layout/stress-layout.monotonic.test.ts | 166 ++++ .../worker/layout/stress-layout.perf.test.ts | 34 +- .../worker/layout/stress-layout.test.ts | 172 ++++ .../worker/layout/stress-layout.ts | 170 ++-- 14 files changed, 1962 insertions(+), 154 deletions(-) create mode 100644 .relayout-contact.png create mode 100644 .relayout-contact2.png create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/community-degree-terms.bench.ts create mode 100644 apps/hash-frontend/src/pages/shared/graph-visualizer-2/worker/layout/stress-layout.monotonic.test.ts diff --git a/.relayout-contact.png b/.relayout-contact.png new file mode 100644 index 0000000000000000000000000000000000000000..b89a30af02a492b4bcf5d113bcee502bc76608a4 GIT binary patch literal 1652842 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
pn2CCZc+Q0E2N67srI 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
G@oi9t$64b4Du&|?T_&FG7aUI$3tVkX5 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\N4l+ri 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

>S^hWGjNN;C*8P%mcux%KBp4_<-m~GiPqA9{_ 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%0D+Lve{P${>-@+N$(wgo<@Vp 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%0AJIFF_`{)5)b@7r^1KnetgT}<4fZthgS=E$%y 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 z