diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b5d9ce..f0d19ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,17 @@ jobs: - name: Install dependencies run: npm ci + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + run: cargo install wasm-pack --locked + + - name: Build native wasm artifacts + run: npm run build:native:wasm + - name: Syntax check run: npm run check diff --git a/.gitignore b/.gitignore index 6fb0a67..2c34420 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .claude/ +native/**/target/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/index.js b/index.js index 3715099..6461056 100644 --- a/index.js +++ b/index.js @@ -23,6 +23,7 @@ import { buildPersistDelta, buildGraphFromSnapshot, buildSnapshotFromGraph, + evaluatePersistNativeDeltaGate, ensureDexieLoaded, } from "./sync/bme-db.js"; import { @@ -661,6 +662,7 @@ function getRuntimeDebugState() { lastUndoResult: null, }, graphPersistence: null, + graphLayout: null, updatedAt: "", }; } @@ -727,6 +729,7 @@ function readRuntimeDebugSnapshot() { messageTrace: state.messageTrace, maintenance: state.maintenance, graphPersistence: state.graphPersistence, + graphLayout: state.graphLayout, updatedAt: state.updatedAt, }, { @@ -743,6 +746,7 @@ function readRuntimeDebugSnapshot() { lastUndoResult: null, }, graphPersistence: null, + graphLayout: null, updatedAt: "", }, ); @@ -755,6 +759,7 @@ let isExtracting = false; let isRecalling = false; let activeRecallPromise = null; let recallRunSequence = 0; +let nativePersistDeltaInstallPromise = null; let lastInjectionContent = ""; let lastExtractedItems = []; // 最近提取的节点(面板展示用) let lastRecalledItems = []; // 最近召回的节点(面板展示用) @@ -948,6 +953,7 @@ function getGraphPersistenceLiveState() { graphPersistenceState.dualWriteLastResult, null, ), + persistDelta: cloneRuntimeDebugValue(graphPersistenceState.persistDelta, null), }; return cloneRuntimeDebugValue(snapshot, snapshot); @@ -967,6 +973,30 @@ function updateGraphPersistenceState(patch = {}) { return graphPersistenceState; } +function readPersistDeltaDiagnosticsNow() { + if (typeof performance === "object" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +} + +function updatePersistDeltaDiagnostics(snapshot = null) { + const nextSnapshot = + snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) + ? { + ...(graphPersistenceState.persistDelta && + typeof graphPersistenceState.persistDelta === "object" && + !Array.isArray(graphPersistenceState.persistDelta) + ? cloneRuntimeDebugValue(graphPersistenceState.persistDelta, {}) + : {}), + ...cloneRuntimeDebugValue(snapshot, {}), + updatedAt: new Date().toISOString(), + } + : null; + updateGraphPersistenceState({ persistDelta: nextSnapshot }); + return nextSnapshot; +} + function bumpGraphRevision(reason = "graph-mutation") { const nextRevision = Math.max( @@ -9653,7 +9683,98 @@ async function saveGraphToIndexedDb( hostChatId: currentIdentity.hostChatId || "", }, }); - const delta = buildPersistDelta(baseSnapshot, snapshot); + const currentSettings = getSettings(); + const nativePersistRequested = currentSettings.persistUseNativeDelta === true; + const nativePersistForceDisabled = currentSettings.graphNativeForceDisable === true; + const nativePersistGate = evaluatePersistNativeDeltaGate( + baseSnapshot, + snapshot, + currentSettings, + ); + const shouldUseNativePersistDelta = + nativePersistRequested && + nativePersistForceDisabled !== true && + nativePersistGate.allowed; + const persistDeltaStartedAt = readPersistDeltaDiagnosticsNow(); + let persistDeltaBuildDiagnostics = null; + let nativePersistModuleStatus = null; + let nativePersistPreloadStatus = nativePersistRequested + ? nativePersistForceDisabled + ? "force-disabled" + : nativePersistGate.allowed + ? "pending" + : "gated-out" + : "not-requested"; + let nativePersistPreloadError = ""; + let nativePersistPreloadMs = 0; + updatePersistDeltaDiagnostics({ + chatId: normalizedChatId, + saveReason: String(reason || "graph-save"), + requestedRevision, + requestedNative: nativePersistRequested, + nativeForceDisabled: nativePersistForceDisabled, + nativeFailOpen: currentSettings.nativeEngineFailOpen !== false, + gateAllowed: nativePersistGate.allowed, + gateReasons: cloneRuntimeDebugValue(nativePersistGate.reasons, []), + preloadGateAllowed: nativePersistGate.allowed, + preloadGateReasons: cloneRuntimeDebugValue(nativePersistGate.reasons, []), + minSnapshotRecords: nativePersistGate.minSnapshotRecords, + minStructuralDelta: nativePersistGate.minStructuralDelta, + minCombinedSerializedChars: nativePersistGate.minCombinedSerializedChars, + beforeRecordCount: nativePersistGate.beforeRecordCount, + afterRecordCount: nativePersistGate.afterRecordCount, + maxSnapshotRecords: nativePersistGate.maxSnapshotRecords, + structuralDelta: nativePersistGate.structuralDelta, + preloadStatus: nativePersistPreloadStatus, + preloadMs: 0, + preloadError: "", + status: "building", + }); + if (shouldUseNativePersistDelta) { + const preloadStartedAt = readPersistDeltaDiagnosticsNow(); + try { + if (!nativePersistDeltaInstallPromise) { + nativePersistDeltaInstallPromise = import("./vendor/wasm/stbme_core.js") + .then((module) => module?.installNativePersistDeltaHook?.()) + .catch((error) => { + nativePersistDeltaInstallPromise = null; + throw error; + }); + } + nativePersistModuleStatus = await nativePersistDeltaInstallPromise; + nativePersistPreloadStatus = nativePersistModuleStatus?.loaded + ? "loaded" + : "not-loaded"; + nativePersistPreloadMs = + readPersistDeltaDiagnosticsNow() - preloadStartedAt; + } catch (error) { + nativePersistPreloadStatus = "failed"; + nativePersistPreloadMs = + readPersistDeltaDiagnosticsNow() - preloadStartedAt; + nativePersistPreloadError = error?.message || String(error); + if (currentSettings.nativeEngineFailOpen !== false) { + console.warn( + "[ST-BME] native persist delta preload failed, fallback to JS delta:", + error, + ); + } else { + throw error; + } + } + } + const delta = buildPersistDelta(baseSnapshot, snapshot, { + useNativeDelta: shouldUseNativePersistDelta, + nativeFailOpen: currentSettings.nativeEngineFailOpen !== false, + persistNativeDeltaThresholdRecords: + currentSettings.persistNativeDeltaThresholdRecords, + persistNativeDeltaThresholdStructuralDelta: + currentSettings.persistNativeDeltaThresholdStructuralDelta, + persistNativeDeltaThresholdSerializedChars: + currentSettings.persistNativeDeltaThresholdSerializedChars, + onDiagnostics(snapshot) { + persistDeltaBuildDiagnostics = snapshot; + }, + }); const commitResult = await db.commitDelta(delta, { reason, requestedRevision, @@ -9695,6 +9816,56 @@ async function saveGraphToIndexedDb( console.warn("[ST-BME] IndexedDB 已写入,但同步上传调度失败:", error); } + const persistDeltaDiagnostics = { + ...cloneRuntimeDebugValue(persistDeltaBuildDiagnostics, {}), + chatId: normalizedChatId, + saveReason: String(reason || "graph-save"), + requestedRevision, + requestedNative: nativePersistRequested, + buildRequestedNative: Boolean(persistDeltaBuildDiagnostics?.requestedNative), + nativeForceDisabled: nativePersistForceDisabled, + nativeFailOpen: currentSettings.nativeEngineFailOpen !== false, + gateAllowed: + persistDeltaBuildDiagnostics?.gateAllowed ?? nativePersistGate.allowed, + gateReasons: cloneRuntimeDebugValue( + persistDeltaBuildDiagnostics?.gateReasons, + nativePersistGate.reasons, + ), + preloadGateAllowed: nativePersistGate.allowed, + preloadGateReasons: cloneRuntimeDebugValue(nativePersistGate.reasons, []), + minSnapshotRecords: nativePersistGate.minSnapshotRecords, + minStructuralDelta: nativePersistGate.minStructuralDelta, + minCombinedSerializedChars: + persistDeltaBuildDiagnostics?.minCombinedSerializedChars ?? + nativePersistGate.minCombinedSerializedChars, + beforeRecordCount: nativePersistGate.beforeRecordCount, + afterRecordCount: nativePersistGate.afterRecordCount, + maxSnapshotRecords: nativePersistGate.maxSnapshotRecords, + structuralDelta: nativePersistGate.structuralDelta, + preloadStatus: nativePersistPreloadStatus, + preloadMs: nativePersistPreloadMs, + preloadError: nativePersistPreloadError, + moduleLoaded: Boolean(nativePersistModuleStatus?.loaded), + moduleSource: String(nativePersistModuleStatus?.source || ""), + moduleError: String( + nativePersistModuleStatus?.error || nativePersistPreloadError || "", + ), + status: "committed", + commitRevision: snapshot.meta.revision, + commitDelta: cloneRuntimeDebugValue(commitResult?.delta, null), + totalMs: readPersistDeltaDiagnosticsNow() - persistDeltaStartedAt, + }; + persistDeltaDiagnostics.fallbackReason = + persistDeltaDiagnostics.requestedNative && !persistDeltaDiagnostics.usedNative + ? String( + (persistDeltaDiagnostics.preloadStatus !== "loaded" && + persistDeltaDiagnostics.preloadStatus !== "pending" + ? persistDeltaDiagnostics.preloadStatus + : persistDeltaDiagnostics.nativeAttemptStatus) || + "js", + ) + : ""; + updateGraphPersistenceState({ revision: snapshot.meta.revision, storagePrimary: "indexeddb", @@ -9735,6 +9906,7 @@ async function saveGraphToIndexedDb( delta: cloneRuntimeDebugValue(commitResult?.delta, null), at: Date.now(), }, + persistDelta: persistDeltaDiagnostics, }); clearPendingGraphPersistRetry(); if ( @@ -9782,6 +9954,11 @@ async function saveGraphToIndexedDb( }; } catch (error) { console.warn("[ST-BME] IndexedDB 写入失败,保鐣?metadata 兜底:", error); + updatePersistDeltaDiagnostics({ + status: "failed", + error: error?.message || String(error), + failedAt: Date.now(), + }); updateGraphPersistenceState({ indexedDbLastError: error?.message || String(error), dualWriteLastResult: { diff --git a/native/stbme-core/Cargo.lock b/native/stbme-core/Cargo.lock new file mode 100644 index 0000000..fe08a90 --- /dev/null +++ b/native/stbme-core/Cargo.lock @@ -0,0 +1,199 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "stbme-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/native/stbme-core/Cargo.toml b/native/stbme-core/Cargo.toml new file mode 100644 index 0000000..b976b7a --- /dev/null +++ b/native/stbme-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "stbme-core" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = "0.6" +wasm-bindgen = "0.2" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/native/stbme-core/src/lib.rs b/native/stbme-core/src/lib.rs new file mode 100644 index 0000000..d4385a0 --- /dev/null +++ b/native/stbme-core/src/lib.rs @@ -0,0 +1,851 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use std::collections::HashMap; +use wasm_bindgen::prelude::*; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LayoutNode { + x: f64, + y: f64, + #[serde(default)] + vx: f64, + #[serde(default)] + vy: f64, + #[serde(default)] + pinned: bool, + #[serde(default)] + radius: f64, + #[serde(default)] + region_key: String, + #[serde(default)] + region_rect: RegionRect, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LayoutEdge { + from: usize, + to: usize, + #[serde(default = "default_strength")] + strength: f64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LayoutConfig { + #[serde(default = "default_iterations")] + iterations: u32, + #[serde(default = "default_repulsion")] + repulsion: f64, + #[serde(default = "default_spring_k")] + spring_k: f64, + #[serde(default = "default_damping")] + damping: f64, + #[serde(default = "default_center_gravity")] + center_gravity: f64, + #[serde(default = "default_min_gap")] + min_gap: f64, + #[serde(default = "default_speed_cap")] + speed_cap: f64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LayoutPayload { + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, + #[serde(default)] + config: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegionRect { + #[serde(default)] + x: f64, + #[serde(default)] + y: f64, + #[serde(default)] + w: f64, + #[serde(default)] + h: f64, +} + +impl Default for RegionRect { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + w: 0.0, + h: 0.0, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LayoutDiagnostics { + solver: String, + node_count: usize, + edge_count: usize, + iterations: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct LayoutResult { + ok: bool, + used_native: bool, + positions: Vec, + diagnostics: LayoutDiagnostics, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PersistSnapshot { + #[serde(default)] + meta: JsonMap, + #[serde(default)] + state: JsonMap, + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, + #[serde(default)] + tombstones: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PersistDeltaPayload { + #[serde(default)] + before_snapshot: PersistSnapshot, + #[serde(default)] + after_snapshot: PersistSnapshot, + #[serde(default)] + now_ms: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PersistDeltaResult { + upsert_nodes: Vec, + upsert_edges: Vec, + delete_node_ids: Vec, + delete_edge_ids: Vec, + tombstones: Vec, + runtime_meta_patch: JsonMap, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PersistCompactRecordSet { + #[serde(default)] + ids: Vec, + #[serde(default)] + serialized: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PersistCompactTombstoneSet { + #[serde(default)] + ids: Vec, + #[serde(default)] + serialized: Vec, + #[serde(default)] + target_keys: Vec, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PersistDeltaCompactPayload { + #[serde(default)] + before_nodes: PersistCompactRecordSet, + #[serde(default)] + after_nodes: PersistCompactRecordSet, + #[serde(default)] + before_edges: PersistCompactRecordSet, + #[serde(default)] + after_edges: PersistCompactRecordSet, + #[serde(default)] + before_tombstones: PersistCompactRecordSet, + #[serde(default)] + after_tombstones: PersistCompactTombstoneSet, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct PersistDeltaIdResult { + upsert_node_ids: Vec, + upsert_edge_ids: Vec, + delete_node_ids: Vec, + delete_edge_ids: Vec, + upsert_tombstone_ids: Vec, +} + +fn default_iterations() -> u32 { + 80 +} + +fn default_repulsion() -> f64 { + 2800.0 +} + +fn default_spring_k() -> f64 { + 0.048 +} + +fn default_damping() -> f64 { + 0.88 +} + +fn default_center_gravity() -> f64 { + 0.014 +} + +fn default_min_gap() -> f64 { + 12.0 +} + +fn default_speed_cap() -> f64 { + 3.8 +} + +fn default_strength() -> f64 { + 0.5 +} + +fn clamp(value: f64, min: f64, max: f64) -> f64 { + if value < min { + min + } else if value > max { + max + } else { + value + } +} + +fn clamp_node_to_region(x: &mut f64, y: &mut f64, radius: f64, rect: &RegionRect) { + let safe_radius = radius.max(1.0) + 6.0; + let min_x = rect.x + safe_radius; + let max_x = rect.x + rect.w - safe_radius; + let min_y = rect.y + safe_radius; + let max_y = rect.y + rect.h - safe_radius; + *x = clamp(*x, min_x, max_x); + *y = clamp(*y, min_y, max_y); +} + +fn normalize_json_record_id(value: Option<&JsonValue>) -> String { + value + .and_then(JsonValue::as_str) + .map(|item| item.trim().to_string()) + .unwrap_or_default() +} + +fn normalize_json_number_i64(value: Option<&JsonValue>, fallback: i64) -> i64 { + match value { + Some(JsonValue::Number(number)) => number.as_f64().unwrap_or(fallback as f64).floor() as i64, + Some(JsonValue::String(text)) => text.parse::().ok().map(|item| item.floor() as i64).unwrap_or(fallback), + _ => fallback, + } +} + +fn sanitize_json_records(records: Vec) -> Vec { + records + .into_iter() + .filter(|record| record.is_object()) + .filter(|record| normalize_json_record_id(record.get("id")).is_empty() == false) + .collect() +} + +fn sanitize_persist_snapshot(snapshot: PersistSnapshot) -> PersistSnapshot { + PersistSnapshot { + meta: snapshot.meta, + state: snapshot.state, + nodes: sanitize_json_records(snapshot.nodes), + edges: sanitize_json_records(snapshot.edges), + tombstones: sanitize_json_records(snapshot.tombstones), + } +} + +fn build_json_serialized_index(records: &[JsonValue]) -> HashMap { + let mut map = HashMap::new(); + for record in records { + let id = normalize_json_record_id(record.get("id")); + if id.is_empty() { + continue; + } + let serialized = serde_json::to_string(record).unwrap_or_else(|_| "null".to_string()); + map.insert(id, serialized); + } + map +} + +fn build_json_value_index(records: &[JsonValue]) -> HashMap { + let mut map = HashMap::new(); + for record in records { + let id = normalize_json_record_id(record.get("id")); + if id.is_empty() { + continue; + } + map.insert(id, record.clone()); + } + map +} + +fn build_runtime_meta_patch(snapshot: &PersistSnapshot) -> JsonMap { + const RESERVED_KEYS: [&str; 8] = [ + "revision", + "lastModified", + "nodeCount", + "edgeCount", + "tombstoneCount", + "syncDirty", + "syncDirtyReason", + "lastMutationReason", + ]; + + let mut patch = JsonMap::new(); + for (key, value) in &snapshot.meta { + if RESERVED_KEYS.contains(&key.as_str()) { + continue; + } + patch.insert(key.clone(), value.clone()); + } + + patch.insert( + "lastProcessedFloor".to_string(), + JsonValue::from(normalize_json_number_i64( + snapshot.state.get("lastProcessedFloor"), + -1, + )), + ); + patch.insert( + "extractionCount".to_string(), + JsonValue::from(normalize_json_number_i64( + snapshot.state.get("extractionCount"), + 0, + )), + ); + patch.insert("schemaVersion".to_string(), JsonValue::from(1)); + patch.insert( + "chatId".to_string(), + JsonValue::from(normalize_json_record_id(snapshot.meta.get("chatId"))), + ); + patch +} + +fn ensure_delete_tombstone( + tombstone_map: &mut HashMap, + kind: &str, + target_id: &str, + deleted_at: i64, + source_device_id: &str, +) { + let normalized_kind = kind.trim(); + let normalized_target_id = target_id.trim(); + if normalized_kind.is_empty() || normalized_target_id.is_empty() { + return; + } + + let key = format!("{}:{}", normalized_kind, normalized_target_id); + if tombstone_map.contains_key(&key) { + return; + } + + let mut record = JsonMap::new(); + record.insert("id".to_string(), JsonValue::from(key.clone())); + record.insert("kind".to_string(), JsonValue::from(normalized_kind)); + record.insert("targetId".to_string(), JsonValue::from(normalized_target_id)); + record.insert("deletedAt".to_string(), JsonValue::from(deleted_at)); + record.insert( + "sourceDeviceId".to_string(), + JsonValue::from(source_device_id.trim().to_string()), + ); + tombstone_map.insert(key, JsonValue::Object(record)); +} + +fn solve_persist_delta_in_rust(payload: PersistDeltaPayload) -> PersistDeltaResult { + let before_snapshot = sanitize_persist_snapshot(payload.before_snapshot); + let after_snapshot = sanitize_persist_snapshot(payload.after_snapshot); + let now_ms = payload.now_ms.unwrap_or(0.0).floor() as i64; + let deleted_at = if now_ms > 0 { now_ms } else { 0 }; + + let before_node_json_by_id = build_json_serialized_index(&before_snapshot.nodes); + let after_node_json_by_id = build_json_serialized_index(&after_snapshot.nodes); + let before_edge_json_by_id = build_json_serialized_index(&before_snapshot.edges); + let after_edge_json_by_id = build_json_serialized_index(&after_snapshot.edges); + let before_tombstone_json_by_id = build_json_serialized_index(&before_snapshot.tombstones); + let after_node_by_id = build_json_value_index(&after_snapshot.nodes); + let after_edge_by_id = build_json_value_index(&after_snapshot.edges); + let after_tombstone_by_id = build_json_value_index(&after_snapshot.tombstones); + + let mut upsert_nodes = Vec::new(); + for (id, record) in &after_node_by_id { + if before_node_json_by_id.get(id) + != Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string())) + { + upsert_nodes.push(record.clone()); + } + } + + let mut upsert_edges = Vec::new(); + for (id, record) in &after_edge_by_id { + if before_edge_json_by_id.get(id) + != Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string())) + { + upsert_edges.push(record.clone()); + } + } + + let mut delete_node_ids = Vec::new(); + for id in before_node_json_by_id.keys() { + if !after_node_json_by_id.contains_key(id) { + delete_node_ids.push(id.clone()); + } + } + + let mut delete_edge_ids = Vec::new(); + for id in before_edge_json_by_id.keys() { + if !after_edge_json_by_id.contains_key(id) { + delete_edge_ids.push(id.clone()); + } + } + + let mut tombstone_map = HashMap::new(); + for (id, record) in &after_tombstone_by_id { + if before_tombstone_json_by_id.get(id) + != Some(&serde_json::to_string(record).unwrap_or_else(|_| "null".to_string())) + { + let kind = normalize_json_record_id(record.get("kind")); + let target_id = normalize_json_record_id(record.get("targetId")); + if kind.is_empty() || target_id.is_empty() { + continue; + } + tombstone_map.insert(format!("{}:{}", kind, target_id), record.clone()); + } + } + + let source_device_id = normalize_json_record_id( + after_snapshot + .meta + .get("deviceId") + .or_else(|| before_snapshot.meta.get("deviceId")), + ); + + for node_id in &delete_node_ids { + ensure_delete_tombstone( + &mut tombstone_map, + "node", + node_id, + deleted_at, + &source_device_id, + ); + } + for edge_id in &delete_edge_ids { + ensure_delete_tombstone( + &mut tombstone_map, + "edge", + edge_id, + deleted_at, + &source_device_id, + ); + } + + PersistDeltaResult { + upsert_nodes, + upsert_edges, + delete_node_ids, + delete_edge_ids, + tombstones: tombstone_map.into_values().collect(), + runtime_meta_patch: build_runtime_meta_patch(&after_snapshot), + } +} + +fn build_compact_serialized_lookup<'a>( + ids: &'a [String], + serialized: &'a [String], +) -> HashMap<&'a str, &'a str> { + let mut map = HashMap::new(); + let len = ids.len().min(serialized.len()); + for index in 0..len { + let id = ids[index].trim(); + if id.is_empty() { + continue; + } + map.insert(id, serialized[index].as_str()); + } + map +} + +fn build_compact_target_key_lookup<'a>( + ids: &'a [String], + target_keys: &'a [String], +) -> HashMap<&'a str, &'a str> { + let mut map = HashMap::new(); + let len = ids.len().min(target_keys.len()); + for index in 0..len { + let id = ids[index].trim(); + if id.is_empty() { + continue; + } + map.insert(id, target_keys[index].trim()); + } + map +} + +fn solve_persist_delta_compact_in_rust(payload: PersistDeltaCompactPayload) -> PersistDeltaIdResult { + let before_node_json_by_id = + build_compact_serialized_lookup(&payload.before_nodes.ids, &payload.before_nodes.serialized); + let after_node_json_by_id = + build_compact_serialized_lookup(&payload.after_nodes.ids, &payload.after_nodes.serialized); + let before_edge_json_by_id = + build_compact_serialized_lookup(&payload.before_edges.ids, &payload.before_edges.serialized); + let after_edge_json_by_id = + build_compact_serialized_lookup(&payload.after_edges.ids, &payload.after_edges.serialized); + let before_tombstone_json_by_id = build_compact_serialized_lookup( + &payload.before_tombstones.ids, + &payload.before_tombstones.serialized, + ); + let after_tombstone_target_key_by_id = build_compact_target_key_lookup( + &payload.after_tombstones.ids, + &payload.after_tombstones.target_keys, + ); + + let mut upsert_node_ids = Vec::new(); + let after_node_len = payload + .after_nodes + .ids + .len() + .min(payload.after_nodes.serialized.len()); + for index in 0..after_node_len { + let id = payload.after_nodes.ids[index].trim(); + if id.is_empty() { + continue; + } + let serialized = payload.after_nodes.serialized[index].as_str(); + if before_node_json_by_id.get(id) != Some(&serialized) { + upsert_node_ids.push(id.to_string()); + } + } + + let mut upsert_edge_ids = Vec::new(); + let after_edge_len = payload + .after_edges + .ids + .len() + .min(payload.after_edges.serialized.len()); + for index in 0..after_edge_len { + let id = payload.after_edges.ids[index].trim(); + if id.is_empty() { + continue; + } + let serialized = payload.after_edges.serialized[index].as_str(); + if before_edge_json_by_id.get(id) != Some(&serialized) { + upsert_edge_ids.push(id.to_string()); + } + } + + let mut delete_node_ids = Vec::new(); + for id in &payload.before_nodes.ids { + let normalized_id = id.trim(); + if normalized_id.is_empty() { + continue; + } + if !after_node_json_by_id.contains_key(normalized_id) { + delete_node_ids.push(normalized_id.to_string()); + } + } + + let mut delete_edge_ids = Vec::new(); + for id in &payload.before_edges.ids { + let normalized_id = id.trim(); + if normalized_id.is_empty() { + continue; + } + if !after_edge_json_by_id.contains_key(normalized_id) { + delete_edge_ids.push(normalized_id.to_string()); + } + } + + let mut upsert_tombstone_ids = Vec::new(); + let after_tombstone_len = payload + .after_tombstones + .ids + .len() + .min(payload.after_tombstones.serialized.len()); + for index in 0..after_tombstone_len { + let id = payload.after_tombstones.ids[index].trim(); + if id.is_empty() { + continue; + } + let target_key = after_tombstone_target_key_by_id + .get(id) + .copied() + .unwrap_or_default(); + if target_key.is_empty() { + continue; + } + let serialized = payload.after_tombstones.serialized[index].as_str(); + if before_tombstone_json_by_id.get(id) != Some(&serialized) { + upsert_tombstone_ids.push(id.to_string()); + } + } + + PersistDeltaIdResult { + upsert_node_ids, + upsert_edge_ids, + delete_node_ids, + delete_edge_ids, + upsert_tombstone_ids, + } +} + +fn build_region_buckets(nodes: &[LayoutNode]) -> HashMap> { + let mut region_buckets = HashMap::new(); + for (index, node) in nodes.iter().enumerate() { + region_buckets + .entry(node.region_key.clone()) + .or_insert_with(Vec::new) + .push(index); + } + region_buckets +} + +fn build_region_spring_ideals(nodes: &[LayoutNode]) -> HashMap { + let mut count_by_region: HashMap = HashMap::new(); + let mut area_by_region: HashMap = HashMap::new(); + + for node in nodes { + *count_by_region.entry(node.region_key.clone()).or_insert(0) += 1; + area_by_region + .entry(node.region_key.clone()) + .or_insert_with(|| { + let area = node.region_rect.w.max(1.0) * node.region_rect.h.max(1.0); + area.max(1.0) + }); + } + + let mut result = HashMap::new(); + for (region_key, count) in count_by_region { + let area = *area_by_region.get(®ion_key).unwrap_or(&1.0); + let count_f64 = (count.max(1)) as f64; + let ideal = (0.78 * (area / count_f64).sqrt()).clamp(36.0, 92.0); + result.insert(region_key, ideal); + } + result +} + +fn build_in_region_edges(nodes: &[LayoutNode], edges: &[LayoutEdge]) -> Vec<(usize, usize, f64)> { + let mut result = Vec::new(); + for edge in edges { + if edge.from >= nodes.len() || edge.to >= nodes.len() || edge.from == edge.to { + continue; + } + if nodes[edge.from].region_key != nodes[edge.to].region_key { + continue; + } + result.push((edge.from, edge.to, edge.strength)); + } + result +} + +fn solve_layout_in_rust(payload: LayoutPayload) -> LayoutResult { + let config = payload.config.unwrap_or(LayoutConfig { + iterations: default_iterations(), + repulsion: default_repulsion(), + spring_k: default_spring_k(), + damping: default_damping(), + center_gravity: default_center_gravity(), + min_gap: default_min_gap(), + speed_cap: default_speed_cap(), + }); + + let mut nodes = payload.nodes; + let edge_count = payload.edges.len(); + + if nodes.is_empty() { + return LayoutResult { + ok: true, + used_native: true, + positions: Vec::new(), + diagnostics: LayoutDiagnostics { + solver: "rust-wasm".to_string(), + node_count: 0, + edge_count, + iterations: 0, + }, + }; + } + + let iterations = clamp(config.iterations as f64, 8.0, 220.0) as u32; + let repulsion = clamp(config.repulsion, 100.0, 120_000.0); + let spring_k = clamp(config.spring_k, 0.001, 1.0); + let damping = clamp(config.damping, 0.1, 0.999); + let center_gravity = clamp(config.center_gravity, 0.0001, 1.0); + let min_gap = clamp(config.min_gap, 0.0, 120.0); + let speed_cap = clamp(config.speed_cap, 0.5, 20.0); + + for node in &mut nodes { + node.radius = node.radius.max(1.0); + } + + let region_buckets = build_region_buckets(&nodes); + let spring_ideal_by_region = build_region_spring_ideals(&nodes); + let in_region_edges = build_in_region_edges(&nodes, &payload.edges); + + let mut center_x = vec![0.0_f64; nodes.len()]; + let mut center_y = vec![0.0_f64; nodes.len()]; + for (index, node) in nodes.iter().enumerate() { + center_x[index] = node.region_rect.x + node.region_rect.w / 2.0; + center_y[index] = node.region_rect.y + node.region_rect.h / 2.0; + } + + let mut fx = vec![0.0_f64; nodes.len()]; + let mut fy = vec![0.0_f64; nodes.len()]; + let mut actual_iterations = 0_u32; + let mut stable_rounds = 0_u32; + + for _ in 0..iterations { + actual_iterations += 1; + fx.fill(0.0); + fy.fill(0.0); + + for bucket in region_buckets.values() { + for left in 0..bucket.len() { + let i = bucket[left]; + for right in (left + 1)..bucket.len() { + let j = bucket[right]; + + let dx = nodes[j].x - nodes[i].x; + let dy = nodes[j].y - nodes[i].y; + let mut dist_sq = dx * dx + dy * dy; + if dist_sq < 0.25 { + dist_sq = 0.25; + } + let dist = dist_sq.sqrt(); + let min_sep = nodes[i].radius + nodes[j].radius + min_gap; + let mut force = repulsion / dist_sq; + if dist < min_sep { + force += (min_sep - dist) * 0.22; + } + let inv_dist = if dist > 0.0 { 1.0 / dist } else { 0.0 }; + let force_x = dx * inv_dist * force; + let force_y = dy * inv_dist * force; + + fx[i] -= force_x; + fy[i] -= force_y; + fx[j] += force_x; + fy[j] += force_y; + } + } + } + + for (from, to, strength) in &in_region_edges { + let from_index = *from; + let to_index = *to; + let strength_value = *strength; + + let dx = nodes[to_index].x - nodes[from_index].x; + let dy = nodes[to_index].y - nodes[from_index].y; + let dist = (dx * dx + dy * dy).sqrt().max(0.001); + let ideal = *spring_ideal_by_region + .get(&nodes[from_index].region_key) + .unwrap_or(&68.0); + let displacement = dist - ideal * (0.82 + 0.18 * strength_value); + let force = spring_k * displacement * (0.45 + 0.55 * strength_value); + let force_x = (dx / dist) * force; + let force_y = (dy / dist) * force; + + fx[from_index] += force_x; + fy[from_index] += force_y; + fx[to_index] -= force_x; + fy[to_index] -= force_y; + } + + let mut max_speed = 0.0_f64; + for (index, node) in nodes.iter_mut().enumerate() { + fx[index] += (center_x[index] - node.x) * center_gravity; + fy[index] += (center_y[index] - node.y) * center_gravity; + + if node.pinned { + continue; + } + + node.vx = (node.vx + fx[index]) * damping; + node.vy = (node.vy + fy[index]) * damping; + let speed = (node.vx * node.vx + node.vy * node.vy).sqrt(); + if speed > max_speed { + max_speed = speed; + } + if speed > speed_cap { + let scale = speed_cap / speed; + node.vx *= scale; + node.vy *= scale; + } + node.x += node.vx; + node.y += node.vy; + clamp_node_to_region(&mut node.x, &mut node.y, node.radius, &node.region_rect); + } + + if max_speed < 0.015 { + stable_rounds += 1; + if stable_rounds >= 6 { + break; + } + } else { + stable_rounds = 0; + } + } + + let node_count = nodes.len(); + let mut positions = Vec::with_capacity(nodes.len() * 2); + for node in nodes { + positions.push(node.x as f32); + positions.push(node.y as f32); + } + + LayoutResult { + ok: true, + used_native: true, + positions, + diagnostics: LayoutDiagnostics { + solver: "rust-wasm".to_string(), + node_count, + edge_count, + iterations: actual_iterations, + }, + } +} + +#[wasm_bindgen] +pub fn solve_layout(payload: JsValue) -> Result { + let parsed: LayoutPayload = serde_wasm_bindgen::from_value(payload) + .map_err(|error| JsValue::from_str(&format!("invalid payload: {error}")))?; + let solved = solve_layout_in_rust(parsed); + serde_wasm_bindgen::to_value(&solved) + .map_err(|error| JsValue::from_str(&format!("serialize result failed: {error}"))) +} + +#[wasm_bindgen] +pub fn build_persist_delta(payload: JsValue) -> Result { + let parsed: PersistDeltaPayload = serde_wasm_bindgen::from_value(payload) + .map_err(|error| JsValue::from_str(&format!("invalid persist payload: {error}")))?; + let solved = solve_persist_delta_in_rust(parsed); + serde_wasm_bindgen::to_value(&solved) + .map_err(|error| JsValue::from_str(&format!("serialize persist result failed: {error}"))) +} + +#[wasm_bindgen] +pub fn build_persist_delta_compact(payload: JsValue) -> Result { + let parsed: PersistDeltaCompactPayload = serde_wasm_bindgen::from_value(payload) + .map_err(|error| JsValue::from_str(&format!("invalid compact persist payload: {error}")))?; + let solved = solve_persist_delta_compact_in_rust(parsed); + serde_wasm_bindgen::to_value(&solved).map_err(|error| { + JsValue::from_str(&format!("serialize compact persist result failed: {error}")) + }) +} diff --git a/package.json b/package.json index dc840c5..ed1ed1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { "version:bump-manifest": "node scripts/bump-manifest-version.mjs", + "build:native:wasm": "node scripts/build-native-wasm.mjs", "test:p0": "node tests/p0-regressions.mjs", "test:runtime-history": "node tests/runtime-history.mjs", "test:graph-persistence": "node tests/graph-persistence.mjs", @@ -9,7 +10,11 @@ "test:indexeddb-persistence": "node tests/indexeddb-persistence.mjs", "test:indexeddb-sync": "node tests/indexeddb-sync.mjs", "test:indexeddb-migration": "node tests/indexeddb-migration.mjs", + "test:native-layout-parity": "node tests/native-layout-parity.mjs", "test:trivial-input": "node tests/trivial-user-input.mjs", + "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", + "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", + "bench:native": "npm run bench:graph-layout && npm run bench:persist-delta", "test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration", "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb", "test:stable": "node scripts/run-test-suite.mjs", diff --git a/runtime/runtime-debug.js b/runtime/runtime-debug.js index 06ccc3f..60f2e9b 100644 --- a/runtime/runtime-debug.js +++ b/runtime/runtime-debug.js @@ -28,6 +28,8 @@ const runtimeDebugState = { taskLlmRequests: {}, injections: {}, taskTimeline: [], + graphPersistence: null, + graphLayout: null, updatedAt: "", }; @@ -41,6 +43,8 @@ export function resetRuntimeDebugSnapshot() { runtimeDebugState.taskLlmRequests = {}; runtimeDebugState.injections = {}; runtimeDebugState.taskTimeline = []; + runtimeDebugState.graphPersistence = null; + runtimeDebugState.graphLayout = null; runtimeDebugState.updatedAt = nowIso(); } @@ -76,6 +80,26 @@ export function recordInjectionSnapshot(kind, snapshot = {}) { touchRuntimeDebugState(); } +export function recordGraphPersistenceSnapshot(snapshot = null) { + runtimeDebugState.graphPersistence = snapshot + ? { + updatedAt: nowIso(), + ...safeClone(snapshot, {}), + } + : null; + touchRuntimeDebugState(); +} + +export function recordGraphLayoutSnapshot(snapshot = null) { + runtimeDebugState.graphLayout = snapshot + ? { + updatedAt: nowIso(), + ...safeClone(snapshot, {}), + } + : null; + touchRuntimeDebugState(); +} + export function getRuntimeDebugSnapshot() { return safeClone( { @@ -84,6 +108,8 @@ export function getRuntimeDebugSnapshot() { taskLlmRequests: runtimeDebugState.taskLlmRequests, injections: runtimeDebugState.injections, taskTimeline: runtimeDebugState.taskTimeline, + graphPersistence: runtimeDebugState.graphPersistence, + graphLayout: runtimeDebugState.graphLayout, updatedAt: runtimeDebugState.updatedAt, }, { @@ -92,6 +118,8 @@ export function getRuntimeDebugSnapshot() { taskLlmRequests: {}, injections: {}, taskTimeline: [], + graphPersistence: null, + graphLayout: null, updatedAt: "", }, ); diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index e216167..06ffdac 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -112,6 +112,18 @@ export const defaultSettings = { embeddingBackendApiUrl: "", embeddingAutoSuffix: true, + // Native 性能加速(灰度) + graphUseNativeLayout: false, + graphNativeLayoutThresholdNodes: 280, + graphNativeLayoutThresholdEdges: 1600, + graphNativeLayoutWorkerTimeoutMs: 260, + persistUseNativeDelta: false, + persistNativeDeltaThresholdRecords: 20000, + persistNativeDeltaThresholdStructuralDelta: 600, + persistNativeDeltaThresholdSerializedChars: 4000000, + nativeEngineFailOpen: true, + graphNativeForceDisable: false, + // Schema nodeTypeSchema: null, diff --git a/scripts/build-native-wasm.mjs b/scripts/build-native-wasm.mjs new file mode 100644 index 0000000..4eb2258 --- /dev/null +++ b/scripts/build-native-wasm.mjs @@ -0,0 +1,61 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const ROOT = process.cwd(); +const CRATE_DIR = path.resolve(ROOT, "native", "stbme-core"); +const OUT_DIR = path.resolve(ROOT, "vendor", "wasm", "pkg"); + +function runCommand(command, args, cwd) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: "inherit", + windowsHide: true, + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`${command} terminated by signal ${signal}`)); + return; + } + if (code !== 0) { + reject(new Error(`${command} exited with code ${code}`)); + return; + } + resolve(); + }); + }); +} + +async function main() { + await mkdir(OUT_DIR, { recursive: true }); + + console.log(`[ST-BME][native] building Rust/WASM from ${CRATE_DIR}`); + await runCommand( + "wasm-pack", + [ + "build", + "--target", + "web", + "--release", + "--out-dir", + path.relative(CRATE_DIR, OUT_DIR), + "--out-name", + "stbme_core_pkg", + ], + CRATE_DIR, + ); + + console.log("[ST-BME][native] wasm artifact build completed"); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error("[ST-BME][native] build failed:", message); + console.error( + "[ST-BME][native] Ensure rustup + wasm32 target + wasm-pack are installed.", + ); + process.exitCode = 1; +}); diff --git a/scripts/check-syntax.mjs b/scripts/check-syntax.mjs index 2944df6..7d781ee 100644 --- a/scripts/check-syntax.mjs +++ b/scripts/check-syntax.mjs @@ -16,6 +16,8 @@ const SOURCE_ROOTS = [ "sync", "ui", "vector", + "vendor/wasm", + "native", ]; async function collectFiles(targetPath) { diff --git a/style.css b/style.css index 84c8fe9..d4de447 100644 --- a/style.css +++ b/style.css @@ -1341,6 +1341,7 @@ color: var(--bme-on-surface-dim); line-height: 1.4; display: -webkit-box; + line-clamp: 2; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; @@ -1845,7 +1846,8 @@ .bme-graph-statusbar { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; + gap: 12px; padding: 4px 12px; background: var(--bme-surface-container); border-top: 1px solid var(--bme-border); @@ -1854,6 +1856,13 @@ flex-shrink: 0; } +.bme-graph-layout-meta { + margin-left: auto; + color: var(--bme-on-surface-dim); + opacity: 0.88; + white-space: nowrap; +} + .bme-status-dot { display: inline-block; width: 6px; diff --git a/sync/bme-db.js b/sync/bme-db.js index 9cd13cb..3a63663 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -15,6 +15,10 @@ export const BME_DB_SCHEMA_VERSION = 1; export const BME_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export const BME_LEGACY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_RECORDS = 20000; +const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_STRUCTURAL_DELTA = 600; +const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_SERIALIZED_CHARS = 4000000; + export const BME_RUNTIME_HISTORY_META_KEY = "runtimeHistoryState"; export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; export const BME_RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; @@ -173,6 +177,147 @@ function sanitizeSnapshot(snapshot = {}) { }; } +function normalizePersistSnapshotView(snapshot = {}) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + return { + meta: {}, + state: {}, + nodes: [], + edges: [], + tombstones: [], + }; + } + + return { + meta: + snapshot.meta && + typeof snapshot.meta === "object" && + !Array.isArray(snapshot.meta) + ? snapshot.meta + : {}, + state: + snapshot.state && + typeof snapshot.state === "object" && + !Array.isArray(snapshot.state) + ? snapshot.state + : {}, + nodes: toArray(snapshot.nodes), + edges: toArray(snapshot.edges), + tombstones: toArray(snapshot.tombstones), + }; +} + +function normalizePersistNativeDeltaThreshold(value, fallbackValue) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallbackValue; + return Math.max(0, Math.floor(parsed)); +} + +function countPersistSnapshotRecords(snapshot = {}) { + return ( + toArray(snapshot?.nodes).length + + toArray(snapshot?.edges).length + + toArray(snapshot?.tombstones).length + ); +} + +function countPersistSnapshotStructuralDelta(beforeSnapshot = {}, afterSnapshot = {}) { + return ( + Math.abs(toArray(afterSnapshot?.nodes).length - toArray(beforeSnapshot?.nodes).length) + + Math.abs(toArray(afterSnapshot?.edges).length - toArray(beforeSnapshot?.edges).length) + + Math.abs( + toArray(afterSnapshot?.tombstones).length - + toArray(beforeSnapshot?.tombstones).length, + ) + ); +} + +export function resolvePersistNativeDeltaGateOptions(options = {}) { + return { + minSnapshotRecords: normalizePersistNativeDeltaThreshold( + options?.persistNativeDeltaThresholdRecords ?? options?.minSnapshotRecords, + DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_RECORDS, + ), + minStructuralDelta: normalizePersistNativeDeltaThreshold( + options?.persistNativeDeltaThresholdStructuralDelta ?? + options?.minStructuralDelta, + DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_STRUCTURAL_DELTA, + ), + minCombinedSerializedChars: normalizePersistNativeDeltaThreshold( + options?.persistNativeDeltaThresholdSerializedChars ?? + options?.minCombinedSerializedChars, + DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_SERIALIZED_CHARS, + ), + }; +} + +export function evaluatePersistNativeDeltaGate( + beforeSnapshot, + afterSnapshot, + options = {}, +) { + const gate = resolvePersistNativeDeltaGateOptions(options); + const beforeRecordCount = countPersistSnapshotRecords(beforeSnapshot); + const afterRecordCount = countPersistSnapshotRecords(afterSnapshot); + const maxSnapshotRecords = Math.max(beforeRecordCount, afterRecordCount); + const measuredCombinedSerializedChars = Number.isFinite( + Number(options?.measuredCombinedSerializedChars ?? options?.combinedSerializedChars), + ) + ? Math.max( + 0, + Math.floor( + Number( + options?.measuredCombinedSerializedChars ?? + options?.combinedSerializedChars, + ), + ), + ) + : null; + const structuralDelta = countPersistSnapshotStructuralDelta( + beforeSnapshot, + afterSnapshot, + ); + const reasons = []; + + if ( + gate.minSnapshotRecords > 0 && + maxSnapshotRecords < gate.minSnapshotRecords + ) { + reasons.push("below-record-threshold"); + } + if (gate.minStructuralDelta > 0 && structuralDelta < gate.minStructuralDelta) { + reasons.push("below-structural-delta-threshold"); + } + if ( + gate.minCombinedSerializedChars > 0 && + measuredCombinedSerializedChars != null && + measuredCombinedSerializedChars < gate.minCombinedSerializedChars + ) { + reasons.push("below-serialized-chars-threshold"); + } + + return { + allowed: reasons.length === 0, + beforeRecordCount, + afterRecordCount, + maxSnapshotRecords, + combinedSerializedChars: measuredCombinedSerializedChars, + structuralDelta, + minSnapshotRecords: gate.minSnapshotRecords, + minStructuralDelta: gate.minStructuralDelta, + minCombinedSerializedChars: gate.minCombinedSerializedChars, + reasons, + }; +} + +export function shouldUseNativePersistDeltaForSnapshots( + beforeSnapshot, + afterSnapshot, + options = {}, +) { + return evaluatePersistNativeDeltaGate(beforeSnapshot, afterSnapshot, options).allowed; +} + function normalizeStateSnapshot(snapshot = {}) { const state = snapshot?.state && @@ -441,28 +586,186 @@ export function buildSnapshotFromGraph(graph, options = {}) { }; } -function buildSnapshotRecordIndex(records = []) { - const map = new Map(); - for (const record of toArray(records)) { - const id = normalizeRecordId(record?.id); - if (!id) continue; - map.set(id, JSON.stringify(record)); +function normalizeSnapshotMetaState(snapshot = {}) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + return { + meta: {}, + state: {}, + }; } - return map; + + return { + meta: + snapshot.meta && + typeof snapshot.meta === "object" && + !Array.isArray(snapshot.meta) + ? snapshot.meta + : {}, + state: + snapshot.state && + typeof snapshot.state === "object" && + !Array.isArray(snapshot.state) + ? snapshot.state + : {}, + }; } -function buildSnapshotRecordArrayIndex(records = []) { - const map = new Map(); +function buildPreparedRecordSet( + records = [], + { + retainRecords = false, + includeTargetKeys = false, + includeSerializedList = false, + includeSerializedCharCount = false, + } = {}, +) { + const ids = []; + const serialized = includeSerializedList ? [] : null; + const serializedById = new Map(); + const recordById = retainRecords ? new Map() : null; + const targetKeyById = includeTargetKeys ? new Map() : null; + let serializedCharCount = 0; + for (const record of toArray(records)) { - const id = normalizeRecordId(record?.id); + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const id = normalizeRecordId(record.id); if (!id) continue; - map.set(id, toPlainData(record, record)); + const json = JSON.stringify(record); + ids.push(id); + if (serialized) serialized.push(json); + serializedById.set(id, json); + if (includeSerializedCharCount) { + serializedCharCount += json.length; + } + if (recordById) recordById.set(id, record); + if (targetKeyById) { + const kind = normalizeRecordId(record.kind); + const targetId = normalizeRecordId(record.targetId); + targetKeyById.set(id, kind && targetId ? `${kind}:${targetId}` : ""); + } } - return map; + + return { + ids, + serialized, + serializedById, + recordById, + targetKeyById, + serializedCharCount, + }; +} + +function buildPreparedPersistDeltaContext( + beforeSnapshot, + afterSnapshot, + nowMs, + options = {}, +) { + const includeCompactPayload = options.includeCompactPayload === true; + const includeSerializedCharCount = options.includeSerializedCharCount === true; + const beforeNodes = buildPreparedRecordSet(beforeSnapshot.nodes, { + includeSerializedList: includeCompactPayload, + includeSerializedCharCount, + }); + const afterNodes = buildPreparedRecordSet(afterSnapshot.nodes, { + retainRecords: true, + includeSerializedList: includeCompactPayload, + includeSerializedCharCount, + }); + const beforeEdges = buildPreparedRecordSet(beforeSnapshot.edges, { + includeSerializedList: includeCompactPayload, + includeSerializedCharCount, + }); + const afterEdges = buildPreparedRecordSet(afterSnapshot.edges, { + retainRecords: true, + includeSerializedList: includeCompactPayload, + includeSerializedCharCount, + }); + const beforeTombstones = buildPreparedRecordSet(beforeSnapshot.tombstones, { + includeSerializedList: includeCompactPayload, + includeSerializedCharCount, + }); + const afterTombstones = buildPreparedRecordSet(afterSnapshot.tombstones, { + retainRecords: true, + includeTargetKeys: true, + includeSerializedList: includeCompactPayload, + includeSerializedCharCount, + }); + const sourceDeviceId = normalizeRecordId( + afterSnapshot.meta?.deviceId || beforeSnapshot.meta?.deviceId || "", + ); + const beforeRecordCount = + beforeNodes.ids.length + beforeEdges.ids.length + beforeTombstones.ids.length; + const afterRecordCount = + afterNodes.ids.length + afterEdges.ids.length + afterTombstones.ids.length; + const beforeSerializedChars = + includeSerializedCharCount + ? beforeNodes.serializedCharCount + + beforeEdges.serializedCharCount + + beforeTombstones.serializedCharCount + : 0; + const afterSerializedChars = + includeSerializedCharCount + ? afterNodes.serializedCharCount + + afterEdges.serializedCharCount + + afterTombstones.serializedCharCount + : 0; + + return { + beforeNodes, + afterNodes, + beforeEdges, + afterEdges, + beforeTombstones, + afterTombstones, + nowMs, + sourceDeviceId, + beforeRecordCount, + afterRecordCount, + maxSnapshotRecords: Math.max(beforeRecordCount, afterRecordCount), + structuralDelta: + Math.abs(afterNodes.ids.length - beforeNodes.ids.length) + + Math.abs(afterEdges.ids.length - beforeEdges.ids.length) + + Math.abs(afterTombstones.ids.length - beforeTombstones.ids.length), + beforeSerializedChars, + afterSerializedChars, + compactPayload: includeCompactPayload + ? { + nowMs, + beforeNodes: { + ids: beforeNodes.ids, + serialized: beforeNodes.serialized, + }, + afterNodes: { + ids: afterNodes.ids, + serialized: afterNodes.serialized, + }, + beforeEdges: { + ids: beforeEdges.ids, + serialized: beforeEdges.serialized, + }, + afterEdges: { + ids: afterEdges.ids, + serialized: afterEdges.serialized, + }, + beforeTombstones: { + ids: beforeTombstones.ids, + serialized: beforeTombstones.serialized, + }, + afterTombstones: { + ids: afterTombstones.ids, + serialized: afterTombstones.serialized, + targetKeys: afterTombstones.ids.map( + (id) => afterTombstones.targetKeyById?.get(id) || "", + ), + }, + } + : null, + }; } function buildRuntimeMetaPatch(snapshot = {}) { - const normalizedSnapshot = sanitizeSnapshot(snapshot); + const normalizedSnapshot = normalizeSnapshotMetaState(snapshot); const patch = {}; for (const [rawKey, value] of Object.entries(normalizedSnapshot.meta || {})) { const key = normalizeRecordId(rawKey); @@ -500,55 +803,349 @@ function ensureDeleteTombstone( }); } +function normalizePersistDeltaShape(delta = null) { + if (!delta || typeof delta !== "object" || Array.isArray(delta)) { + return null; + } + + const toObjectArray = (value) => + Array.isArray(value) + ? value + .filter((item) => item && typeof item === "object" && !Array.isArray(item)) + .map((item) => toPlainData(item, item)) + : []; + const toStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => normalizeRecordId(item)) + .filter((item) => item.length > 0) + : []; + const runtimeMetaPatch = + delta.runtimeMetaPatch && + typeof delta.runtimeMetaPatch === "object" && + !Array.isArray(delta.runtimeMetaPatch) + ? toPlainData(delta.runtimeMetaPatch, {}) + : {}; + + return { + upsertNodes: toObjectArray(delta.upsertNodes), + upsertEdges: toObjectArray(delta.upsertEdges), + deleteNodeIds: toStringArray(delta.deleteNodeIds), + deleteEdgeIds: toStringArray(delta.deleteEdgeIds), + tombstones: toObjectArray(delta.tombstones), + runtimeMetaPatch, + }; +} + +function normalizePersistDeltaIdShape(delta = null) { + if (!delta || typeof delta !== "object" || Array.isArray(delta)) { + return null; + } + + const hasFullShapeFields = + Object.prototype.hasOwnProperty.call(delta, "upsertNodes") || + Object.prototype.hasOwnProperty.call(delta, "upsertEdges") || + Object.prototype.hasOwnProperty.call(delta, "tombstones"); + if (hasFullShapeFields) return null; + + const hasIdShape = + Object.prototype.hasOwnProperty.call(delta, "upsertNodeIds") || + Object.prototype.hasOwnProperty.call(delta, "upsertEdgeIds") || + Object.prototype.hasOwnProperty.call(delta, "deleteNodeIds") || + Object.prototype.hasOwnProperty.call(delta, "deleteEdgeIds") || + Object.prototype.hasOwnProperty.call(delta, "upsertTombstoneIds"); + if (!hasIdShape) return null; + + const toStringArray = (value) => + Array.isArray(value) + ? value + .map((item) => normalizeRecordId(item)) + .filter((item) => item.length > 0) + : []; + + return { + upsertNodeIds: toStringArray(delta.upsertNodeIds), + upsertEdgeIds: toStringArray(delta.upsertEdgeIds), + deleteNodeIds: toStringArray(delta.deleteNodeIds), + deleteEdgeIds: toStringArray(delta.deleteEdgeIds), + upsertTombstoneIds: toStringArray(delta.upsertTombstoneIds), + }; +} + +function hydratePreparedRecords(recordById, ids = []) { + const output = []; + if (!(recordById instanceof Map)) return output; + for (const id of ids) { + const record = recordById.get(normalizeRecordId(id)); + if (!record) continue; + output.push(record); + } + return output; +} + +function buildPersistDeltaFromIdShape(preparedContext, delta = null) { + const normalized = normalizePersistDeltaIdShape(delta); + if (!normalized) return null; + + const tombstoneMap = new Map(); + for (const id of normalized.upsertTombstoneIds) { + const record = preparedContext.afterTombstones.recordById?.get(id); + const targetKey = preparedContext.afterTombstones.targetKeyById?.get(id) || ""; + if (!record || !targetKey) continue; + tombstoneMap.set(targetKey, record); + } + + for (const nodeId of normalized.deleteNodeIds) { + ensureDeleteTombstone( + tombstoneMap, + "node", + nodeId, + preparedContext.nowMs, + preparedContext.sourceDeviceId, + ); + } + for (const edgeId of normalized.deleteEdgeIds) { + ensureDeleteTombstone( + tombstoneMap, + "edge", + edgeId, + preparedContext.nowMs, + preparedContext.sourceDeviceId, + ); + } + + return { + upsertNodes: hydratePreparedRecords( + preparedContext.afterNodes.recordById, + normalized.upsertNodeIds, + ), + upsertEdges: hydratePreparedRecords( + preparedContext.afterEdges.recordById, + normalized.upsertEdgeIds, + ), + deleteNodeIds: normalized.deleteNodeIds, + deleteEdgeIds: normalized.deleteEdgeIds, + tombstones: Array.from(tombstoneMap.values()), + runtimeMetaPatch: {}, + }; +} + +function readPersistDeltaNow() { + if (typeof performance === "object" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +} + +function emitPersistDeltaDiagnostics(options = {}, snapshot = null) { + if (typeof options?.onDiagnostics !== "function") return; + try { + options.onDiagnostics(snapshot ? toPlainData(snapshot, snapshot) : null); + } catch { + // ignore diagnostics callback failures + } +} + +function tryBuildNativePersistDelta( + beforeSnapshot, + afterSnapshot, + preparedContext, + options = {}, +) { + if (options?.useNativeDelta !== true) { + return { + rawDelta: null, + status: "not-requested", + error: "", + }; + } + const nativeBuilder = globalThis.__stBmeNativeBuildPersistDelta; + if (typeof nativeBuilder !== "function") { + if (options?.nativeFailOpen === false) { + throw new Error("native-persist-delta-builder-unavailable"); + } + return { + rawDelta: null, + status: "builder-unavailable", + error: "native-persist-delta-builder-unavailable", + }; + } + + try { + return { + rawDelta: nativeBuilder(beforeSnapshot, afterSnapshot, { + nowMs: options?.nowMs, + preparedDeltaInput: preparedContext?.compactPayload || null, + }), + status: "ok", + error: "", + }; + } catch (error) { + if (options?.nativeFailOpen === false) { + throw error; + } + return { + rawDelta: null, + status: "builder-error", + error: error?.message || String(error), + }; + } +} + export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { - const normalizedBefore = sanitizeSnapshot(beforeSnapshot); - const normalizedAfter = sanitizeSnapshot(afterSnapshot); + const shouldCollectDiagnostics = typeof options?.onDiagnostics === "function"; + const startedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; + const normalizedBefore = normalizePersistSnapshotView(beforeSnapshot); + const normalizedAfter = normalizePersistSnapshotView(afterSnapshot); const nowMs = normalizeTimestamp(options.nowMs, Date.now()); - const beforeNodeJsonById = buildSnapshotRecordIndex(normalizedBefore.nodes); - const afterNodeJsonById = buildSnapshotRecordIndex(normalizedAfter.nodes); - const beforeEdgeJsonById = buildSnapshotRecordIndex(normalizedBefore.edges); - const afterEdgeJsonById = buildSnapshotRecordIndex(normalizedAfter.edges); - const beforeTombstoneJsonById = buildSnapshotRecordIndex( - normalizedBefore.tombstones, - ); - const afterNodeById = buildSnapshotRecordArrayIndex(normalizedAfter.nodes); - const afterEdgeById = buildSnapshotRecordArrayIndex(normalizedAfter.edges); - const afterTombstoneById = buildSnapshotRecordArrayIndex( - normalizedAfter.tombstones, + const nativeGateOptions = + options?.useNativeDelta === true + ? resolvePersistNativeDeltaGateOptions(options) + : null; + const shouldMeasureSerializedChars = + shouldCollectDiagnostics || + (options?.useNativeDelta === true && + (nativeGateOptions?.minCombinedSerializedChars || 0) > 0); + const preparedContext = buildPreparedPersistDeltaContext( + normalizedBefore, + normalizedAfter, + nowMs, + { + includeCompactPayload: options?.useNativeDelta === true, + includeSerializedCharCount: shouldMeasureSerializedChars, + }, ); + const combinedSerializedChars = + preparedContext.beforeSerializedChars + preparedContext.afterSerializedChars; + const preparedNativeGate = + options?.useNativeDelta === true + ? evaluatePersistNativeDeltaGate(normalizedBefore, normalizedAfter, { + minSnapshotRecords: nativeGateOptions?.minSnapshotRecords, + minStructuralDelta: nativeGateOptions?.minStructuralDelta, + minCombinedSerializedChars: nativeGateOptions?.minCombinedSerializedChars, + measuredCombinedSerializedChars: combinedSerializedChars, + }) + : null; + + const nativeAttempt = + options?.useNativeDelta !== true + ? { + rawDelta: null, + status: "not-requested", + error: "", + } + : preparedNativeGate?.allowed === false + ? { + rawDelta: null, + status: "gated-out", + error: "", + } + : tryBuildNativePersistDelta( + normalizedBefore, + normalizedAfter, + preparedContext, + options, + ); + const nativeRawDelta = nativeAttempt.rawDelta; + const nativeIdDelta = normalizePersistDeltaIdShape(nativeRawDelta); + const nativeDelta = nativeIdDelta + ? buildPersistDeltaFromIdShape(preparedContext, nativeIdDelta) + : normalizePersistDeltaShape(nativeRawDelta); + if (nativeRawDelta && !nativeDelta) { + if (options?.nativeFailOpen === false) { + throw new Error("native-persist-delta-invalid-result"); + } + nativeAttempt.status = "invalid-result"; + nativeAttempt.error = "native-persist-delta-invalid-result"; + } + if (nativeDelta) { + const result = { + ...nativeDelta, + runtimeMetaPatch: { + ...buildRuntimeMetaPatch(normalizedAfter), + ...nativeDelta.runtimeMetaPatch, + ...(options.runtimeMetaPatch && + typeof options.runtimeMetaPatch === "object" && + !Array.isArray(options.runtimeMetaPatch) + ? toPlainData(options.runtimeMetaPatch, {}) + : {}), + }, + }; + if (shouldCollectDiagnostics) { + emitPersistDeltaDiagnostics(options, { + requestedNative: options?.useNativeDelta === true, + usedNative: true, + path: nativeIdDelta ? "native-compact" : "native-full", + gateAllowed: preparedNativeGate?.allowed ?? false, + gateReasons: preparedNativeGate?.reasons || [], + nativeAttemptStatus: nativeAttempt.status, + nativeError: nativeAttempt.error, + beforeRecordCount: preparedContext.beforeRecordCount, + afterRecordCount: preparedContext.afterRecordCount, + maxSnapshotRecords: preparedContext.maxSnapshotRecords, + combinedSerializedChars, + structuralDelta: preparedContext.structuralDelta, + beforeSerializedChars: preparedContext.beforeSerializedChars, + afterSerializedChars: preparedContext.afterSerializedChars, + minCombinedSerializedChars: + preparedNativeGate?.minCombinedSerializedChars || 0, + buildMs: readPersistDeltaNow() - startedAt, + upsertNodeCount: result.upsertNodes.length, + upsertEdgeCount: result.upsertEdges.length, + deleteNodeCount: result.deleteNodeIds.length, + deleteEdgeCount: result.deleteEdgeIds.length, + tombstoneCount: result.tombstones.length, + }); + } + return result; + } const upsertNodes = []; - for (const [id, record] of afterNodeById.entries()) { - if (beforeNodeJsonById.get(id) !== JSON.stringify(record)) { - upsertNodes.push(record); + for (const id of preparedContext.afterNodes.ids) { + if ( + preparedContext.beforeNodes.serializedById.get(id) !== + preparedContext.afterNodes.serializedById.get(id) + ) { + const record = preparedContext.afterNodes.recordById?.get(id); + if (record) upsertNodes.push(record); } } const upsertEdges = []; - for (const [id, record] of afterEdgeById.entries()) { - if (beforeEdgeJsonById.get(id) !== JSON.stringify(record)) { - upsertEdges.push(record); + for (const id of preparedContext.afterEdges.ids) { + if ( + preparedContext.beforeEdges.serializedById.get(id) !== + preparedContext.afterEdges.serializedById.get(id) + ) { + const record = preparedContext.afterEdges.recordById?.get(id); + if (record) upsertEdges.push(record); } } const deleteNodeIds = []; - for (const id of beforeNodeJsonById.keys()) { - if (!afterNodeJsonById.has(id)) { + for (const id of preparedContext.beforeNodes.ids) { + if (!preparedContext.afterNodes.serializedById.has(id)) { deleteNodeIds.push(id); } } const deleteEdgeIds = []; - for (const id of beforeEdgeJsonById.keys()) { - if (!afterEdgeJsonById.has(id)) { + for (const id of preparedContext.beforeEdges.ids) { + if (!preparedContext.afterEdges.serializedById.has(id)) { deleteEdgeIds.push(id); } } const tombstoneMap = new Map(); - for (const [id, record] of afterTombstoneById.entries()) { - if (beforeTombstoneJsonById.get(id) !== JSON.stringify(record)) { - tombstoneMap.set(`${record.kind}:${record.targetId}`, record); + for (const id of preparedContext.afterTombstones.ids) { + if ( + preparedContext.beforeTombstones.serializedById.get(id) !== + preparedContext.afterTombstones.serializedById.get(id) + ) { + const record = preparedContext.afterTombstones.recordById?.get(id); + const targetKey = preparedContext.afterTombstones.targetKeyById?.get(id) || ""; + if (!record || !targetKey) continue; + tombstoneMap.set(targetKey, record); } } @@ -557,8 +1154,8 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { tombstoneMap, "node", nodeId, - nowMs, - normalizedAfter.meta?.deviceId || normalizedBefore.meta?.deviceId || "", + preparedContext.nowMs, + preparedContext.sourceDeviceId, ); } for (const edgeId of deleteEdgeIds) { @@ -566,12 +1163,12 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { tombstoneMap, "edge", edgeId, - nowMs, - normalizedAfter.meta?.deviceId || normalizedBefore.meta?.deviceId || "", + preparedContext.nowMs, + preparedContext.sourceDeviceId, ); } - return { + const result = { upsertNodes, upsertEdges, deleteNodeIds, @@ -586,6 +1183,33 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) { : {}), }, }; + if (shouldCollectDiagnostics) { + emitPersistDeltaDiagnostics(options, { + requestedNative: options?.useNativeDelta === true, + usedNative: false, + path: "js", + gateAllowed: preparedNativeGate?.allowed ?? false, + gateReasons: preparedNativeGate?.reasons || [], + nativeAttemptStatus: nativeAttempt.status, + nativeError: nativeAttempt.error, + beforeRecordCount: preparedContext.beforeRecordCount, + afterRecordCount: preparedContext.afterRecordCount, + maxSnapshotRecords: preparedContext.maxSnapshotRecords, + combinedSerializedChars, + structuralDelta: preparedContext.structuralDelta, + beforeSerializedChars: preparedContext.beforeSerializedChars, + afterSerializedChars: preparedContext.afterSerializedChars, + minCombinedSerializedChars: + preparedNativeGate?.minCombinedSerializedChars || 0, + buildMs: readPersistDeltaNow() - startedAt, + upsertNodeCount: result.upsertNodes.length, + upsertEdgeCount: result.upsertEdges.length, + deleteNodeCount: result.deleteNodeIds.length, + deleteEdgeCount: result.deleteEdgeIds.length, + tombstoneCount: result.tombstones.length, + }); + } + return result; } export function buildGraphFromSnapshot(snapshot, options = {}) { diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index d05be94..39eaf70 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -67,6 +67,16 @@ assert.equal(defaultSettings.worldInfoFilterMode, "default"); assert.equal(defaultSettings.worldInfoFilterCustomKeywords, ""); assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false); assert.equal(defaultSettings.embeddingTransportMode, "direct"); +assert.equal(defaultSettings.graphUseNativeLayout, false); +assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280); +assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600); +assert.equal(defaultSettings.graphNativeLayoutWorkerTimeoutMs, 260); +assert.equal(defaultSettings.persistUseNativeDelta, false); +assert.equal(defaultSettings.persistNativeDeltaThresholdRecords, 20000); +assert.equal(defaultSettings.persistNativeDeltaThresholdStructuralDelta, 600); +assert.equal(defaultSettings.persistNativeDeltaThresholdSerializedChars, 4000000); +assert.equal(defaultSettings.nativeEngineFailOpen, true); +assert.equal(defaultSettings.graphNativeForceDisable, false); assert.equal(defaultSettings.taskProfilesVersion, 3); assert.ok(defaultSettings.taskProfiles); assert.ok(defaultSettings.taskProfiles.extract); diff --git a/tests/graph-layout-solver.mjs b/tests/graph-layout-solver.mjs new file mode 100644 index 0000000..c2b9e47 --- /dev/null +++ b/tests/graph-layout-solver.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; + +import { solveLayoutWithJs } from "../ui/graph-layout-solver.js"; + +const payload = { + nodes: [ + { + x: 20, + y: 20, + vx: 0, + vy: 0, + pinned: false, + radius: 8, + regionKey: "objective", + regionRect: { x: 0, y: 0, w: 200, h: 160 }, + }, + { + x: 80, + y: 30, + vx: 0, + vy: 0, + pinned: false, + radius: 9, + regionKey: "objective", + regionRect: { x: 0, y: 0, w: 200, h: 160 }, + }, + { + x: 100, + y: 80, + vx: 0, + vy: 0, + pinned: true, + radius: 10, + regionKey: "objective", + regionRect: { x: 0, y: 0, w: 200, h: 160 }, + }, + ], + edges: [ + { from: 0, to: 1, strength: 0.8 }, + { from: 1, to: 2, strength: 0.6 }, + ], + config: { + iterations: 32, + repulsion: 2200, + springK: 0.05, + damping: 0.87, + centerGravity: 0.02, + minGap: 10, + speedCap: 3, + }, +}; + +const resultA = solveLayoutWithJs(payload); +assert.equal(resultA.ok, true); +assert.ok(resultA.positions instanceof Float32Array); +assert.equal(resultA.positions.length, payload.nodes.length * 2); +assert.equal(resultA.diagnostics.solver, "js-worker"); +assert.equal(resultA.diagnostics.nodeCount, 3); + +const resultB = solveLayoutWithJs(payload); +assert.deepEqual(Array.from(resultA.positions), Array.from(resultB.positions)); + +for (let index = 0; index < payload.nodes.length; index++) { + const x = resultA.positions[index * 2]; + const y = resultA.positions[index * 2 + 1]; + assert.ok(Number.isFinite(x)); + assert.ok(Number.isFinite(y)); + assert.ok(x >= 0 && x <= 200); + assert.ok(y >= 0 && y <= 160); +} + +const emptyResult = solveLayoutWithJs({ nodes: [], edges: [] }); +assert.equal(emptyResult.ok, true); +assert.equal(emptyResult.positions.length, 0); + +console.log("graph-layout-solver tests passed"); diff --git a/tests/graph-native-bridge.mjs b/tests/graph-native-bridge.mjs new file mode 100644 index 0000000..86b40cb --- /dev/null +++ b/tests/graph-native-bridge.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; + +import { + GraphNativeLayoutBridge, + normalizeGraphNativeRuntimeOptions, +} from "../ui/graph-native-bridge.js"; + +const normalized = normalizeGraphNativeRuntimeOptions({ + graphUseNativeLayout: "true", + graphNativeLayoutThresholdNodes: "333.7", + graphNativeLayoutThresholdEdges: -100, + graphNativeLayoutWorkerTimeoutMs: 10, + nativeEngineFailOpen: "false", + graphNativeForceDisable: 1, +}); + +assert.equal(normalized.graphUseNativeLayout, true); +assert.equal(normalized.graphNativeLayoutThresholdNodes, 333); +assert.equal(normalized.graphNativeLayoutThresholdEdges, 1); +assert.equal(normalized.graphNativeLayoutWorkerTimeoutMs, 40); +assert.equal(normalized.nativeEngineFailOpen, false); +assert.equal(normalized.graphNativeForceDisable, true); + +const runBridge = new GraphNativeLayoutBridge({ + graphUseNativeLayout: true, + graphNativeLayoutThresholdNodes: 100, + graphNativeLayoutThresholdEdges: 200, +}); + +assert.equal(runBridge.shouldRunForGraph(99, 199), false); +assert.equal(runBridge.shouldRunForGraph(100, 10), true); +assert.equal(runBridge.shouldRunForGraph(10, 200), true); + +runBridge._ensureWorker = () => null; +runBridge._workerBootError = "forced-unavailable"; +const failOpenResult = await runBridge.solveLayout({ nodes: [] }); +assert.equal(failOpenResult.ok, false); +assert.equal(failOpenResult.skipped, true); +assert.equal(failOpenResult.reason, "worker-unavailable"); +assert.equal(failOpenResult.error, "forced-unavailable"); + +const strictBridge = new GraphNativeLayoutBridge({ + graphUseNativeLayout: true, + nativeEngineFailOpen: false, +}); +strictBridge._ensureWorker = () => null; +strictBridge._workerBootError = "forced-hard-failure"; +let strictThrew = false; +try { + await strictBridge.solveLayout({ nodes: [] }); +} catch (error) { + strictThrew = String(error?.message || "") === "forced-hard-failure"; +} +assert.equal(strictThrew, true); + +const cancelBridge = new GraphNativeLayoutBridge({ graphUseNativeLayout: true }); +let postMessages = []; +cancelBridge._worker = { + postMessage(message) { + postMessages.push(message); + }, +}; +let canceledResolveCount = 0; +cancelBridge._pendingJobs.set(11, { + timer: setTimeout(() => {}, 5000), + resolve(result) { + canceledResolveCount += 1; + assert.equal(result.reason, "manual-cancel"); + }, +}); +cancelBridge.cancelPending("manual-cancel"); +assert.equal(canceledResolveCount, 1); +assert.equal(cancelBridge._pendingJobs.size, 0); +assert.deepEqual(postMessages, [ + { type: "cancel-layout", jobId: 11, reason: "manual-cancel" }, +]); + +const disposedBridge = new GraphNativeLayoutBridge({ graphUseNativeLayout: true }); +disposedBridge.dispose(); +const disposedResult = await disposedBridge.solveLayout({ nodes: [] }); +assert.equal(disposedResult.reason, "bridge-disposed"); + +console.log("graph-native-bridge tests passed"); diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 3969a4c..186f4cf 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -9,6 +9,7 @@ import { buildGraphFromSnapshot, buildPersistDelta, buildSnapshotFromGraph, + evaluatePersistNativeDeltaGate, } from "../sync/bme-db.js"; import { onMessageReceivedController } from "../host/event-binding.js"; import { @@ -880,6 +881,7 @@ async function createGraphPersistenceHarness({ buildGraphFromSnapshot, buildPersistDelta, buildSnapshotFromGraph, + evaluatePersistNativeDeltaGate, buildBmeDbName, scheduleUpload() { if (runtimeContext.__scheduleUploadShouldThrow) { @@ -2619,6 +2621,12 @@ result = { 7, "附属步骤失败时,IndexedDB 主写仍应视为成功", ); + const persistDeltaDiagnostics = harness.api.getGraphPersistenceState().persistDelta; + assert.equal(Boolean(persistDeltaDiagnostics), true); + assert.equal(persistDeltaDiagnostics.status, "committed"); + assert.equal(persistDeltaDiagnostics.path, "js"); + assert.equal(persistDeltaDiagnostics.requestedNative, false); + assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.buildMs)), true); } { diff --git a/tests/native-layout-parity.mjs b/tests/native-layout-parity.mjs new file mode 100644 index 0000000..8917e13 --- /dev/null +++ b/tests/native-layout-parity.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; + +import { solveLayoutWithJs } from "../ui/graph-layout-solver.js"; +import { + getNativeModuleStatus, + solveLayout, +} from "../vendor/wasm/stbme_core.js"; + +const originalLoader = globalThis.__stBmeLoadRustWasmLayout; + +function buildPayload(seed = 42, nodeCount = 180) { + let state = seed; + const rand = () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; + + const regionRect = { x: 0, y: 0, w: 980, h: 620 }; + const nodes = []; + for (let i = 0; i < nodeCount; i++) { + nodes.push({ + x: regionRect.x + rand() * regionRect.w, + y: regionRect.y + rand() * regionRect.h, + vx: 0, + vy: 0, + pinned: false, + radius: 6 + rand() * 8, + regionKey: "objective", + regionRect, + }); + } + + const edges = []; + const edgeTarget = Math.max(nodeCount * 4, 1); + for (let i = 0; i < edgeTarget; i++) { + const from = Math.floor(rand() * nodeCount); + let to = Math.floor(rand() * nodeCount); + if (to === from) to = (to + 1) % nodeCount; + edges.push({ + from, + to, + strength: 0.25 + rand() * 0.75, + }); + } + + return { + nodes, + edges, + config: { + iterations: 52, + repulsion: 2600, + springK: 0.05, + damping: 0.87, + centerGravity: 0.015, + minGap: 11, + speedCap: 3.2, + }, + }; +} + +function meanAbsDiff(a = new Float32Array(0), b = new Float32Array(0)) { + const len = Math.min(a.length, b.length); + if (len === 0) return 0; + let sum = 0; + for (let i = 0; i < len; i++) { + sum += Math.abs(a[i] - b[i]); + } + return sum / len; +} + +try { + globalThis.__stBmeLoadRustWasmLayout = async () => ({ + solve_layout(payload) { + const jsResult = solveLayoutWithJs(payload); + return { + ok: true, + positions: Array.from(jsResult.positions), + diagnostics: { + solver: "mock-rust-wasm", + nodeCount: jsResult.diagnostics.nodeCount, + edgeCount: jsResult.diagnostics.edgeCount, + iterations: jsResult.diagnostics.iterations, + }, + }; + }, + }); + + const payload = buildPayload(); + const jsResult = solveLayoutWithJs(payload); + const nativeResult = await solveLayout(payload); + + assert.equal(jsResult.ok, true); + assert.equal(nativeResult.ok, true); + assert.ok(nativeResult.positions instanceof Float32Array); + assert.equal(jsResult.positions.length, nativeResult.positions.length); + + const mad = meanAbsDiff(jsResult.positions, nativeResult.positions); + const nativeStatus = getNativeModuleStatus(); + const threshold = nativeStatus.source === "wasm-pack-artifact" ? 2e-4 : 1e-6; + assert.ok( + mad <= threshold, + `mean abs diff too high: ${mad} (source=${nativeStatus.source || "unknown"}, threshold=${threshold})`, + ); +} finally { + if (typeof originalLoader === "function") { + globalThis.__stBmeLoadRustWasmLayout = originalLoader; + } else { + delete globalThis.__stBmeLoadRustWasmLayout; + } +} + +console.log("native-layout-parity tests passed"); diff --git a/tests/native-layout-wrapper.mjs b/tests/native-layout-wrapper.mjs new file mode 100644 index 0000000..52512dd --- /dev/null +++ b/tests/native-layout-wrapper.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; + +function importFreshWrapper(tag = "default") { + return import(`../vendor/wasm/stbme_core.js?test=${Date.now()}-${tag}`); +} + +const originalLoader = globalThis.__stBmeLoadRustWasmLayout; + +try { + globalThis.__stBmeDisableWasmPackArtifacts = true; + globalThis.__stBmeLoadRustWasmLayout = async () => ({ + solve_layout(payload = {}) { + const nodeCount = Array.isArray(payload?.nodes) ? payload.nodes.length : 0; + return { + ok: true, + positions: [1, 2, 3, 4], + diagnostics: { + solver: "mock-loader", + nodeCount, + edgeCount: 0, + iterations: 1, + }, + }; + }, + build_persist_delta_compact(payload = {}) { + return { + upsertNodeIds: Array.isArray(payload?.afterNodes?.ids) + ? payload.afterNodes.ids.slice(0, 1) + : [], + upsertEdgeIds: [], + deleteNodeIds: [], + deleteEdgeIds: [], + upsertTombstoneIds: [], + }; + }, + build_persist_delta(payload = {}) { + return { + upsertNodes: [{ id: "persist-native-node", marker: payload?.afterSnapshot?.meta?.chatId || "" }], + upsertEdges: [], + deleteNodeIds: [], + deleteEdgeIds: [], + tombstones: [], + runtimeMetaPatch: { native: true }, + }; + }, + }); + + const wrapper = await importFreshWrapper("global-loader"); + const result = await wrapper.solveLayout({ nodes: [{}, {}], edges: [] }); + assert.equal(result.ok, true); + assert.equal(result.usedNative, true); + assert.ok(result.positions instanceof Float32Array); + assert.deepEqual(Array.from(result.positions), [1, 2, 3, 4]); + assert.equal(result.diagnostics.solver, "mock-loader"); + + const status = wrapper.getNativeModuleStatus(); + assert.equal(status.loaded, true); + assert.equal(status.source, "global-loader"); + + const installStatus = await wrapper.installNativePersistDeltaHook(); + assert.equal(installStatus.loaded, true); + assert.equal(typeof globalThis.__stBmeNativeBuildPersistDelta, "function"); + const compactDeltaResult = globalThis.__stBmeNativeBuildPersistDelta( + { meta: { chatId: "before-chat" }, nodes: [], edges: [], tombstones: [], state: {} }, + { meta: { chatId: "after-chat" }, nodes: [], edges: [], tombstones: [], state: {} }, + { + nowMs: 123, + preparedDeltaInput: { + afterNodes: { ids: ["persist-native-node"], serialized: ["{}"] }, + }, + }, + ); + assert.deepEqual(compactDeltaResult.upsertNodeIds, ["persist-native-node"]); + + const deltaResult = globalThis.__stBmeNativeBuildPersistDelta( + { meta: { chatId: "before-chat" }, nodes: [], edges: [], tombstones: [], state: {} }, + { meta: { chatId: "after-chat" }, nodes: [], edges: [], tombstones: [], state: {} }, + { nowMs: 123 }, + ); + assert.deepEqual(deltaResult.upsertNodes, [{ id: "persist-native-node", marker: "after-chat" }]); + assert.equal(deltaResult.runtimeMetaPatch.native, true); + + delete globalThis.__stBmeLoadRustWasmLayout; + delete globalThis.__stBmeNativeBuildPersistDelta; + delete globalThis.__stBmeDisableWasmPackArtifacts; + + const wrapperNoLoader = await importFreshWrapper("no-loader"); + let rejected = false; + try { + const resultNoLoader = await wrapperNoLoader.solveLayout({ + nodes: [{ x: 0, y: 0, vx: 0, vy: 0, pinned: false, radius: 8, regionKey: "objective", regionRect: { x: 0, y: 0, w: 200, h: 120 } }], + edges: [], + }); + assert.equal(resultNoLoader.ok, true); + assert.equal(resultNoLoader.usedNative, true); + const statusNoLoader = wrapperNoLoader.getNativeModuleStatus(); + assert.equal(statusNoLoader.loaded, true); + assert.equal(statusNoLoader.source, "wasm-pack-artifact"); + } catch (error) { + rejected = true; + assert.match( + String(error?.message || ""), + /Rust\/WASM artifact is not initialized|native module unavailable|wasm-pack load error/i, + ); + } +} finally { + if (typeof originalLoader === "function") { + globalThis.__stBmeLoadRustWasmLayout = originalLoader; + } else { + delete globalThis.__stBmeLoadRustWasmLayout; + } + delete globalThis.__stBmeDisableWasmPackArtifacts; + delete globalThis.__stBmeNativeBuildPersistDelta; +} + +console.log("native-layout-wrapper tests passed"); diff --git a/tests/native-persist-delta-hook.mjs b/tests/native-persist-delta-hook.mjs new file mode 100644 index 0000000..b5ffcd0 --- /dev/null +++ b/tests/native-persist-delta-hook.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; + +import { + buildPersistDelta, + evaluatePersistNativeDeltaGate, + resolvePersistNativeDeltaGateOptions, + shouldUseNativePersistDeltaForSnapshots, +} from "../sync/bme-db.js"; + +const beforeSnapshot = { + meta: { chatId: "chat-native", revision: 1, lastModified: 1 }, + state: { lastProcessedFloor: 1, extractionCount: 1 }, + nodes: [{ id: "n1", type: "event", fields: { text: "before" }, updatedAt: 1 }], + edges: [], + tombstones: [], +}; + +const afterSnapshot = { + meta: { chatId: "chat-native", revision: 2, lastModified: 2 }, + state: { lastProcessedFloor: 2, extractionCount: 2 }, + nodes: [{ id: "n1", type: "event", fields: { text: "after" }, updatedAt: 2 }], + edges: [], + tombstones: [], +}; + +let fallbackDiagnostics = null; +const fallbackDelta = buildPersistDelta(beforeSnapshot, afterSnapshot, { + onDiagnostics(snapshot) { + fallbackDiagnostics = snapshot; + }, +}); +assert.equal(fallbackDelta.upsertNodes.length, 1); +assert.equal(fallbackDelta.deleteNodeIds.length, 0); +assert.equal(fallbackDiagnostics.path, "js"); +assert.equal(fallbackDiagnostics.requestedNative, false); +assert.equal(fallbackDiagnostics.usedNative, false); +assert.equal(Number.isFinite(fallbackDiagnostics.buildMs), true); + +const defaultGate = resolvePersistNativeDeltaGateOptions({}); +assert.equal(defaultGate.minSnapshotRecords, 20000); +assert.equal(defaultGate.minStructuralDelta, 600); +assert.equal(defaultGate.minCombinedSerializedChars, 4000000); +assert.equal( + shouldUseNativePersistDeltaForSnapshots(beforeSnapshot, afterSnapshot, defaultGate), + false, +); +const payloadGate = evaluatePersistNativeDeltaGate(beforeSnapshot, afterSnapshot, { + minSnapshotRecords: 0, + minStructuralDelta: 0, + minCombinedSerializedChars: 200, + measuredCombinedSerializedChars: 120, +}); +assert.equal(payloadGate.allowed, false); +assert.deepEqual(payloadGate.reasons, ["below-serialized-chars-threshold"]); +assert.equal( + shouldUseNativePersistDeltaForSnapshots(beforeSnapshot, afterSnapshot, { + minSnapshotRecords: 1, + minStructuralDelta: 0, + }), + true, +); + +const largeBeforeSnapshot = { + nodes: new Array(20500).fill(0), + edges: new Array(200).fill(0), + tombstones: [], +}; +const largeAfterSnapshot = { + nodes: new Array(21120).fill(0), + edges: new Array(200).fill(0), + tombstones: [], +}; +assert.equal( + shouldUseNativePersistDeltaForSnapshots( + largeBeforeSnapshot, + largeAfterSnapshot, + defaultGate, + ), + true, +); + +const originalNativeBuilder = globalThis.__stBmeNativeBuildPersistDelta; + +globalThis.__stBmeNativeBuildPersistDelta = () => ({ + upsertNodes: [{ id: "native-node" }], + upsertEdges: [{ id: "native-edge" }], + deleteNodeIds: ["native-delete-node"], + deleteEdgeIds: ["native-delete-edge"], + tombstones: [{ id: "node:native-delete-node", kind: "node", targetId: "native-delete-node" }], + runtimeMetaPatch: { native: true }, +}); + +let nativeDiagnostics = null; +const nativeDelta = buildPersistDelta(beforeSnapshot, afterSnapshot, { + useNativeDelta: true, + minSnapshotRecords: 0, + minStructuralDelta: 0, + minCombinedSerializedChars: 0, + runtimeMetaPatch: { jsPatch: true }, + onDiagnostics(snapshot) { + nativeDiagnostics = snapshot; + }, +}); +assert.deepEqual(nativeDelta.upsertNodes, [{ id: "native-node" }]); +assert.deepEqual(nativeDelta.deleteNodeIds, ["native-delete-node"]); +assert.equal(nativeDelta.runtimeMetaPatch.native, true); +assert.equal(nativeDelta.runtimeMetaPatch.jsPatch, true); +assert.equal(nativeDiagnostics.path, "native-full"); +assert.equal(nativeDiagnostics.requestedNative, true); +assert.equal(nativeDiagnostics.usedNative, true); + +let payloadGateDiagnostics = null; +let payloadGateBuilderCalled = false; +globalThis.__stBmeNativeBuildPersistDelta = () => { + payloadGateBuilderCalled = true; + return { upsertNodes: [] }; +}; +const payloadGatedDelta = buildPersistDelta(beforeSnapshot, afterSnapshot, { + useNativeDelta: true, + minSnapshotRecords: 0, + minStructuralDelta: 0, + minCombinedSerializedChars: 1000, + onDiagnostics(snapshot) { + payloadGateDiagnostics = snapshot; + }, +}); +assert.equal(payloadGateBuilderCalled, false); +assert.equal(payloadGatedDelta.upsertNodes.length, 1); +assert.equal(payloadGateDiagnostics.path, "js"); +assert.equal(payloadGateDiagnostics.nativeAttemptStatus, "gated-out"); +assert.equal(payloadGateDiagnostics.gateAllowed, false); +assert.deepEqual(payloadGateDiagnostics.gateReasons, ["below-serialized-chars-threshold"]); + +globalThis.__stBmeNativeBuildPersistDelta = (_before, _after, options = {}) => { + assert.equal(Boolean(options?.preparedDeltaInput), true); + return { + upsertNodeIds: ["n1"], + upsertEdgeIds: [], + deleteNodeIds: [], + deleteEdgeIds: [], + upsertTombstoneIds: [], + }; +}; + +let compactDiagnostics = null; +const compactNativeDelta = buildPersistDelta(beforeSnapshot, afterSnapshot, { + useNativeDelta: true, + minSnapshotRecords: 0, + minStructuralDelta: 0, + minCombinedSerializedChars: 0, + runtimeMetaPatch: { compact: true }, + onDiagnostics(snapshot) { + compactDiagnostics = snapshot; + }, +}); +assert.deepEqual(compactNativeDelta.upsertNodes, [ + { id: "n1", type: "event", fields: { text: "after" }, updatedAt: 2 }, +]); +assert.deepEqual(compactNativeDelta.upsertEdges, []); +assert.deepEqual(compactNativeDelta.deleteNodeIds, []); +assert.equal(compactNativeDelta.runtimeMetaPatch.compact, true); +assert.equal(compactNativeDelta.runtimeMetaPatch.chatId, "chat-native"); +assert.equal(compactDiagnostics.path, "native-compact"); +assert.equal(compactDiagnostics.usedNative, true); + +delete globalThis.__stBmeNativeBuildPersistDelta; + +let threwUnavailable = false; +try { + buildPersistDelta(beforeSnapshot, afterSnapshot, { + useNativeDelta: true, + minSnapshotRecords: 0, + minStructuralDelta: 0, + minCombinedSerializedChars: 0, + nativeFailOpen: false, + }); +} catch (error) { + threwUnavailable = + String(error?.message || "") === "native-persist-delta-builder-unavailable"; +} +assert.equal(threwUnavailable, true); + +if (typeof originalNativeBuilder === "function") { + globalThis.__stBmeNativeBuildPersistDelta = originalNativeBuilder; +} + +console.log("native-persist-delta-hook tests passed"); diff --git a/tests/perf/graph-layout-bench.mjs b/tests/perf/graph-layout-bench.mjs new file mode 100644 index 0000000..7c21aef --- /dev/null +++ b/tests/perf/graph-layout-bench.mjs @@ -0,0 +1,158 @@ +import { performance } from "node:perf_hooks"; + +import { solveLayoutWithJs } from "../../ui/graph-layout-solver.js"; +import { + getNativeModuleStatus, + solveLayout as solveNativeLayout, +} from "../../vendor/wasm/stbme_core.js"; + +const SCALES = [ + { nodes: 600, edgeMultiplier: 3 }, + { nodes: 1200, edgeMultiplier: 4 }, + { nodes: 2000, edgeMultiplier: 4 }, +]; + +const RUNS = 3; + +function buildPayload(seed = 7, nodeCount = 600, edgeMultiplier = 4) { + let state = seed >>> 0; + const rand = () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; + + const regionRect = { x: 0, y: 0, w: 1280, h: 780 }; + const nodes = new Array(nodeCount).fill(null).map(() => ({ + x: regionRect.x + rand() * regionRect.w, + y: regionRect.y + rand() * regionRect.h, + vx: 0, + vy: 0, + pinned: false, + radius: 5.5 + rand() * 8, + regionKey: "objective", + regionRect, + })); + + const edgeCount = Math.max(1, Math.floor(nodeCount * edgeMultiplier)); + const edges = []; + for (let i = 0; i < edgeCount; i++) { + const from = Math.floor(rand() * nodeCount); + let to = Math.floor(rand() * nodeCount); + if (to === from) to = (to + 1) % nodeCount; + edges.push({ + from, + to, + strength: 0.25 + rand() * 0.75, + }); + } + + return { + nodes, + edges, + config: { + iterations: 56, + repulsion: 2600, + springK: 0.05, + damping: 0.87, + centerGravity: 0.015, + minGap: 11, + speedCap: 3.2, + }, + }; +} + +function summarize(values = []) { + if (!values.length) return { avg: 0, p95: 0, min: 0, max: 0 }; + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((acc, value) => acc + value, 0); + const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95)); + return { + avg: sum / sorted.length, + p95: sorted[p95Index], + min: sorted[0], + max: sorted[sorted.length - 1], + }; +} + +async function runNative(payload) { + const start = performance.now(); + const result = await solveNativeLayout(payload); + const elapsed = performance.now() - start; + return { elapsed, result }; +} + +function runJs(payload) { + const start = performance.now(); + const result = solveLayoutWithJs(payload); + const elapsed = performance.now() - start; + return { elapsed, result }; +} + +async function warmUp() { + const payload = buildPayload(12345, 320, 3); + runJs(payload); + await runNative(payload); +} + +async function main() { + const originalLoader = globalThis.__stBmeLoadRustWasmLayout; + if (typeof originalLoader !== "function") { + globalThis.__stBmeLoadRustWasmLayout = async () => ({ + solve_layout(payload) { + const jsResult = solveLayoutWithJs(payload); + return { + ok: true, + positions: Array.from(jsResult.positions), + diagnostics: { + solver: "mock-rust-wasm", + nodeCount: jsResult.diagnostics.nodeCount, + edgeCount: jsResult.diagnostics.edgeCount, + iterations: jsResult.diagnostics.iterations, + }, + }; + }, + }); + } + + try { + await warmUp(); + const nativeStatus = getNativeModuleStatus(); + console.log(`[ST-BME][bench] graph-layout runs=${RUNS}`); + console.log( + `[ST-BME][bench] graph-layout native-source=${nativeStatus.source || "unknown"}`, + ); + for (const scale of SCALES) { + const jsTimes = []; + const nativeTimes = []; + for (let run = 0; run < RUNS; run++) { + const payload = buildPayload( + scale.nodes * 31 + run, + scale.nodes, + scale.edgeMultiplier, + ); + const js = runJs(payload); + jsTimes.push(js.elapsed); + + const native = await runNative(payload); + nativeTimes.push(native.elapsed); + } + + const jsSummary = summarize(jsTimes); + const nativeSummary = summarize(nativeTimes); + console.log( + `[ST-BME][bench] nodes=${scale.nodes} edges≈${Math.floor(scale.nodes * scale.edgeMultiplier)} | js avg=${jsSummary.avg.toFixed(2)}ms p95=${jsSummary.p95.toFixed(2)}ms | native avg=${nativeSummary.avg.toFixed(2)}ms p95=${nativeSummary.p95.toFixed(2)}ms`, + ); + } + } finally { + if (typeof originalLoader === "function") { + globalThis.__stBmeLoadRustWasmLayout = originalLoader; + } else { + delete globalThis.__stBmeLoadRustWasmLayout; + } + } +} + +main().catch((error) => { + console.error("[ST-BME][bench] graph-layout failed:", error?.message || String(error)); + process.exitCode = 1; +}); diff --git a/tests/perf/persist-delta-bench.mjs b/tests/perf/persist-delta-bench.mjs new file mode 100644 index 0000000..80764a1 --- /dev/null +++ b/tests/perf/persist-delta-bench.mjs @@ -0,0 +1,161 @@ +import { performance } from "node:perf_hooks"; + +import { buildPersistDelta } from "../../sync/bme-db.js"; +import { + getNativeModuleStatus, + installNativePersistDeltaHook, +} from "../../vendor/wasm/stbme_core.js"; + +const RUNS = 5; + +function buildSnapshots(seed = 5, nodeCount = 5000, edgeCount = 12000, churn = 0.1) { + let state = seed >>> 0; + const rand = () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0xffffffff; + }; + + const beforeNodes = []; + for (let i = 0; i < nodeCount; i++) { + beforeNodes.push({ + id: `n-${i}`, + type: "event", + fields: { + text: `node-${i}`, + v: Math.floor(rand() * 1000), + }, + archived: false, + updatedAt: 1000 + i, + }); + } + + const beforeEdges = []; + for (let i = 0; i < edgeCount; i++) { + const from = Math.floor(rand() * nodeCount); + let to = Math.floor(rand() * nodeCount); + if (to === from) to = (to + 1) % nodeCount; + beforeEdges.push({ + id: `e-${i}`, + fromId: `n-${from}`, + toId: `n-${to}`, + relation: "related", + strength: rand(), + updatedAt: 1000 + i, + }); + } + + const afterNodes = beforeNodes.map((node) => ({ ...node, fields: { ...node.fields } })); + const afterEdges = beforeEdges.map((edge) => ({ ...edge })); + + const mutateNodeCount = Math.floor(nodeCount * churn); + for (let i = 0; i < mutateNodeCount; i++) { + const index = Math.floor(rand() * afterNodes.length); + afterNodes[index].fields.v = Math.floor(rand() * 5000); + afterNodes[index].updatedAt += 100; + } + + const addNodeCount = Math.max(1, Math.floor(nodeCount * churn * 0.25)); + const baseNodeId = afterNodes.length; + for (let i = 0; i < addNodeCount; i++) { + afterNodes.push({ + id: `n-new-${baseNodeId + i}`, + type: "event", + fields: { text: `new-${i}`, v: Math.floor(rand() * 3000) }, + archived: false, + updatedAt: 5000 + i, + }); + } + + const removeEdgeCount = Math.max(1, Math.floor(edgeCount * churn * 0.2)); + afterEdges.splice(0, removeEdgeCount); + + return { + before: { + meta: { chatId: "bench-chat", revision: 1, lastModified: 1000 }, + state: { lastProcessedFloor: 1, extractionCount: 1 }, + nodes: beforeNodes, + edges: beforeEdges, + tombstones: [], + }, + after: { + meta: { chatId: "bench-chat", revision: 2, lastModified: 2000 }, + state: { lastProcessedFloor: 2, extractionCount: 2 }, + nodes: afterNodes, + edges: afterEdges, + tombstones: [], + }, + }; +} + +function summarize(values = []) { + if (!values.length) return { avg: 0, p95: 0, min: 0, max: 0 }; + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((acc, value) => acc + value, 0); + const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95)); + return { + avg: sum / sorted.length, + p95: sorted[p95Index], + min: sorted[0], + max: sorted[sorted.length - 1], + }; +} + +async function main() { + await installNativePersistDeltaHook(); + const nativeStatus = getNativeModuleStatus(); + const jsSamples = []; + const nativeSamples = []; + for (let run = 0; run < RUNS; run++) { + const snapshots = buildSnapshots(17 + run, 5000, 12000, 0.12); + const jsStartedAt = performance.now(); + const jsDelta = buildPersistDelta(snapshots.before, snapshots.after, { + useNativeDelta: false, + }); + const jsElapsedMs = performance.now() - jsStartedAt; + jsSamples.push({ + elapsedMs: jsElapsedMs, + upsertNodes: jsDelta.upsertNodes.length, + upsertEdges: jsDelta.upsertEdges.length, + deleteNodeIds: jsDelta.deleteNodeIds.length, + deleteEdgeIds: jsDelta.deleteEdgeIds.length, + }); + + const nativeStartedAt = performance.now(); + const nativeDelta = buildPersistDelta(snapshots.before, snapshots.after, { + useNativeDelta: true, + minSnapshotRecords: 0, + minStructuralDelta: 0, + minCombinedSerializedChars: 0, + nativeFailOpen: false, + }); + const nativeElapsedMs = performance.now() - nativeStartedAt; + nativeSamples.push({ + elapsedMs: nativeElapsedMs, + upsertNodes: nativeDelta.upsertNodes.length, + upsertEdges: nativeDelta.upsertEdges.length, + deleteNodeIds: nativeDelta.deleteNodeIds.length, + deleteEdgeIds: nativeDelta.deleteEdgeIds.length, + }); + } + + const jsTimingSummary = summarize(jsSamples.map((sample) => sample.elapsedMs)); + const nativeTimingSummary = summarize(nativeSamples.map((sample) => sample.elapsedMs)); + const avgUpserts = + jsSamples.reduce((acc, sample) => acc + sample.upsertNodes + sample.upsertEdges, 0) / + jsSamples.length; + const avgDeletes = + jsSamples.reduce((acc, sample) => acc + sample.deleteNodeIds + sample.deleteEdgeIds, 0) / + jsSamples.length; + + console.log( + `[ST-BME][bench] persist-delta native-source=${nativeStatus.source || "unknown"}`, + ); + console.log( + `[ST-BME][bench] persist-delta runs=${RUNS} | js avg=${jsTimingSummary.avg.toFixed(2)}ms p95=${jsTimingSummary.p95.toFixed(2)}ms min=${jsTimingSummary.min.toFixed(2)}ms max=${jsTimingSummary.max.toFixed(2)}ms | native avg=${nativeTimingSummary.avg.toFixed(2)}ms p95=${nativeTimingSummary.p95.toFixed(2)}ms min=${nativeTimingSummary.min.toFixed(2)}ms max=${nativeTimingSummary.max.toFixed(2)}ms | avgUpserts=${avgUpserts.toFixed(1)} avgDeletes=${avgDeletes.toFixed(1)}`, + ); +} + +main().catch((error) => { + console.error("[ST-BME][bench] persist-delta failed:", error?.message || String(error)); + process.exitCode = 1; +}); diff --git a/ui/graph-layout-solver.js b/ui/graph-layout-solver.js new file mode 100644 index 0000000..b09a28d --- /dev/null +++ b/ui/graph-layout-solver.js @@ -0,0 +1,303 @@ +const DEFAULT_LAYOUT_CONFIG = Object.freeze({ + iterations: 80, + repulsion: 2800, + springK: 0.048, + damping: 0.88, + centerGravity: 0.014, + minGap: 12, + speedCap: 3.8, +}); + +function clampFinite(value, fallback = 0, min = -Infinity, max = Infinity) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, numeric)); +} + +function normalizeConfig(raw = {}) { + return { + iterations: Math.max( + 8, + Math.min(220, Math.floor(clampFinite(raw.iterations, DEFAULT_LAYOUT_CONFIG.iterations, 1, 220))), + ), + repulsion: clampFinite(raw.repulsion, DEFAULT_LAYOUT_CONFIG.repulsion, 100, 120000), + springK: clampFinite(raw.springK, DEFAULT_LAYOUT_CONFIG.springK, 0.001, 1.0), + damping: clampFinite(raw.damping, DEFAULT_LAYOUT_CONFIG.damping, 0.1, 0.999), + centerGravity: clampFinite( + raw.centerGravity, + DEFAULT_LAYOUT_CONFIG.centerGravity, + 0.0001, + 1, + ), + minGap: clampFinite(raw.minGap, DEFAULT_LAYOUT_CONFIG.minGap, 0, 120), + speedCap: clampFinite(raw.speedCap, DEFAULT_LAYOUT_CONFIG.speedCap, 0.5, 20), + }; +} + +function normalizePanel(raw = null) { + if (!raw || typeof raw !== "object") { + return { x: 0, y: 0, w: 0, h: 0 }; + } + return { + x: clampFinite(raw.x, 0), + y: clampFinite(raw.y, 0), + w: Math.max(0, clampFinite(raw.w, 0)), + h: Math.max(0, clampFinite(raw.h, 0)), + }; +} + +function normalizeNode(raw = {}) { + const rect = normalizePanel(raw.regionRect); + return { + x: clampFinite(raw.x, 0), + y: clampFinite(raw.y, 0), + vx: clampFinite(raw.vx, 0), + vy: clampFinite(raw.vy, 0), + pinned: raw.pinned === true, + radius: Math.max(1, clampFinite(raw.radius, 8, 1, 96)), + regionKey: String(raw.regionKey || "objective"), + regionRect: rect, + }; +} + +function normalizeEdge(raw = {}, nodeCount = 0) { + const from = Math.floor(clampFinite(raw.from, -1)); + const to = Math.floor(clampFinite(raw.to, -1)); + if (from < 0 || to < 0 || from >= nodeCount || to >= nodeCount || from === to) { + return null; + } + return { + from, + to, + strength: clampFinite(raw.strength, 0.5, 0, 1), + }; +} + +function clampNodeToRegion(state, index) { + const rect = state.rects[index]; + const radius = state.radius[index] + 6; + state.x[index] = Math.max(rect.x + radius, Math.min(rect.x + rect.w - radius, state.x[index])); + state.y[index] = Math.max(rect.y + radius, Math.min(rect.y + rect.h - radius, state.y[index])); +} + +function computeSpringIdealByRegion(nodes = []) { + const countByRegion = new Map(); + for (const node of nodes) { + countByRegion.set(node.regionKey, (countByRegion.get(node.regionKey) || 0) + 1); + } + const idealByRegion = new Map(); + for (const node of nodes) { + if (idealByRegion.has(node.regionKey)) continue; + const rect = node.regionRect; + const count = Math.max(1, countByRegion.get(node.regionKey) || 1); + const area = Math.max(1, (rect?.w || 1) * (rect?.h || 1)); + const ideal = Math.max(36, Math.min(92, 0.78 * Math.sqrt(area / count))); + idealByRegion.set(node.regionKey, ideal); + } + return idealByRegion; +} + +function buildRegionBuckets(nodes = []) { + const bucketByRegion = new Map(); + for (let index = 0; index < nodes.length; index++) { + const key = nodes[index].regionKey; + if (!bucketByRegion.has(key)) { + bucketByRegion.set(key, []); + } + bucketByRegion.get(key).push(index); + } + return bucketByRegion; +} + +function buildInRegionEdges(nodes = [], edges = []) { + const result = []; + for (const edge of edges) { + const fromRegion = nodes[edge.from]?.regionKey; + const toRegion = nodes[edge.to]?.regionKey; + if (!fromRegion || fromRegion !== toRegion) continue; + result.push({ + from: edge.from, + to: edge.to, + strength: edge.strength, + }); + } + return result; +} + +function buildRegionCenters(nodes = []) { + const centerX = new Float32Array(nodes.length); + const centerY = new Float32Array(nodes.length); + for (let index = 0; index < nodes.length; index++) { + const rect = nodes[index]?.regionRect || { x: 0, y: 0, w: 0, h: 0 }; + centerX[index] = rect.x + rect.w / 2; + centerY[index] = rect.y + rect.h / 2; + } + return { centerX, centerY }; +} + +function buildSimulationState(nodes = []) { + const length = nodes.length; + const x = new Float32Array(length); + const y = new Float32Array(length); + const vx = new Float32Array(length); + const vy = new Float32Array(length); + const fx = new Float32Array(length); + const fy = new Float32Array(length); + const radius = new Float32Array(length); + const pinned = new Uint8Array(length); + const rects = new Array(length); + + for (let i = 0; i < length; i++) { + const node = nodes[i]; + x[i] = node.x; + y[i] = node.y; + vx[i] = node.vx; + vy[i] = node.vy; + radius[i] = node.radius; + pinned[i] = node.pinned ? 1 : 0; + rects[i] = node.regionRect; + } + + return { + x, + y, + vx, + vy, + fx, + fy, + radius, + pinned, + rects, + }; +} + +export function solveLayoutWithJs(payload = {}) { + const startedAt = performance.now(); + const nodes = Array.isArray(payload.nodes) ? payload.nodes.map(normalizeNode) : []; + const config = normalizeConfig(payload.config || {}); + const edgesRaw = Array.isArray(payload.edges) ? payload.edges : []; + const edges = edgesRaw + .map((edge) => normalizeEdge(edge, nodes.length)) + .filter(Boolean); + + if (nodes.length === 0) { + return { + ok: true, + positions: new Float32Array(0), + diagnostics: { + nodeCount: 0, + edgeCount: 0, + elapsedMs: 0, + solver: "js-worker", + }, + }; + } + + const springIdealByRegion = computeSpringIdealByRegion(nodes); + const regionBuckets = buildRegionBuckets(nodes); + const state = buildSimulationState(nodes); + const inRegionEdges = buildInRegionEdges(nodes, edges); + const { centerX, centerY } = buildRegionCenters(nodes); + let actualIterations = 0; + let stableRounds = 0; + + for (let iter = 0; iter < config.iterations; iter++) { + actualIterations += 1; + state.fx.fill(0); + state.fy.fill(0); + + for (const indexes of regionBuckets.values()) { + for (let i = 0; i < indexes.length; i++) { + const a = indexes[i]; + for (let j = i + 1; j < indexes.length; j++) { + const b = indexes[j]; + const dx = state.x[b] - state.x[a]; + const dy = state.y[b] - state.y[a]; + let distSq = dx * dx + dy * dy; + if (distSq < 0.25) distSq = 0.25; + const dist = Math.sqrt(distSq); + const minSep = state.radius[a] + state.radius[b] + config.minGap; + let force = config.repulsion / distSq; + if (dist < minSep) { + force += (minSep - dist) * 0.22; + } + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + state.fx[a] -= fx; + state.fy[a] -= fy; + state.fx[b] += fx; + state.fy[b] += fy; + } + } + } + + for (const edge of inRegionEdges) { + const from = edge.from; + const to = edge.to; + const ideal = springIdealByRegion.get(nodes[from].regionKey) ?? 68; + const dx = state.x[to] - state.x[from]; + const dy = state.y[to] - state.y[from]; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.001; + const displacement = dist - ideal * (0.82 + 0.18 * edge.strength); + const force = config.springK * displacement * (0.45 + 0.55 * edge.strength); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + state.fx[from] += fx; + state.fy[from] += fy; + state.fx[to] -= fx; + state.fy[to] -= fy; + } + + for (let i = 0; i < nodes.length; i++) { + state.fx[i] += (centerX[i] - state.x[i]) * config.centerGravity; + state.fy[i] += (centerY[i] - state.y[i]) * config.centerGravity; + } + + let maxSpeed = 0; + for (let i = 0; i < nodes.length; i++) { + if (state.pinned[i]) { + continue; + } + state.vx[i] = (state.vx[i] + state.fx[i]) * config.damping; + state.vy[i] = (state.vy[i] + state.fy[i]) * config.damping; + const speed = Math.hypot(state.vx[i], state.vy[i]); + if (speed > maxSpeed) { + maxSpeed = speed; + } + if (speed > config.speedCap) { + state.vx[i] = (state.vx[i] / speed) * config.speedCap; + state.vy[i] = (state.vy[i] / speed) * config.speedCap; + } + state.x[i] += state.vx[i]; + state.y[i] += state.vy[i]; + clampNodeToRegion(state, i); + } + + if (maxSpeed < 0.015) { + stableRounds += 1; + if (stableRounds >= 6) { + break; + } + } else { + stableRounds = 0; + } + } + + const positions = new Float32Array(nodes.length * 2); + for (let i = 0; i < nodes.length; i++) { + positions[i * 2] = state.x[i]; + positions[i * 2 + 1] = state.y[i]; + } + + return { + ok: true, + positions, + diagnostics: { + nodeCount: nodes.length, + edgeCount: edges.length, + elapsedMs: Math.max(0, performance.now() - startedAt), + solver: "js-worker", + iterations: actualIterations, + }, + }; +} diff --git a/ui/graph-layout-worker.js b/ui/graph-layout-worker.js new file mode 100644 index 0000000..3253cbd --- /dev/null +++ b/ui/graph-layout-worker.js @@ -0,0 +1,141 @@ +import { solveLayoutWithJs } from "./graph-layout-solver.js"; + +let nativeSolver = null; +let nativeLoadAttempted = false; +let nativeLoadError = ""; +let readNativeModuleStatus = null; +const canceledJobIds = new Set(); +const activeJobIds = new Set(); + +async function ensureNativeSolver() { + if (nativeLoadAttempted) return nativeSolver; + nativeLoadAttempted = true; + + try { + const nativeModule = await import("../vendor/wasm/stbme_core.js"); + const solveLayout = + nativeModule?.solveLayout || nativeModule?.default?.solveLayout || null; + nativeSolver = typeof solveLayout === "function" ? solveLayout : null; + readNativeModuleStatus = + typeof nativeModule?.getNativeModuleStatus === "function" + ? nativeModule.getNativeModuleStatus + : null; + } catch (error) { + nativeLoadError = error?.message || String(error); + nativeSolver = null; + } + + return nativeSolver; +} + +async function solveLayout(payload = {}) { + const nativeRequested = payload?.nativeRequested === true; + if (nativeRequested) { + const solver = await ensureNativeSolver(); + if (solver) { + try { + const nativeResult = await solver(payload); + const nativeStatus = + typeof readNativeModuleStatus === "function" + ? readNativeModuleStatus() + : null; + if ( + nativeResult && + nativeResult.ok === true && + nativeResult.positions instanceof Float32Array + ) { + return { + ...nativeResult, + usedNative: true, + diagnostics: { + ...(nativeResult.diagnostics || {}), + solver: "rust-wasm", + moduleSource: String(nativeStatus?.source || ""), + nativeLoadError: String(nativeStatus?.error || nativeLoadError || ""), + }, + }; + } + } catch (error) { + return { + ok: false, + skipped: true, + reason: "native-solver-failed", + error: error?.message || String(error), + nativeLoadError, + }; + } + } + } + + const jsResult = solveLayoutWithJs(payload); + return { + ...jsResult, + usedNative: false, + nativeLoadError, + }; +} + +self.addEventListener("message", async (event) => { + const message = event?.data || {}; + if (message.type === "cancel-layout") { + const canceledJobId = Number(message.jobId); + if (Number.isFinite(canceledJobId)) { + canceledJobIds.add(canceledJobId); + } + return; + } + if (message.type !== "solve-layout") return; + + const jobId = Number(message.jobId); + if (!Number.isFinite(jobId)) return; + if (canceledJobIds.has(jobId)) { + canceledJobIds.delete(jobId); + return; + } + activeJobIds.add(jobId); + const fallbackErrorResult = (errorMessage = "unknown-worker-error") => ({ + ok: false, + skipped: true, + reason: "worker-exception", + error: String(errorMessage || "unknown-worker-error"), + }); + + try { + const result = await solveLayout(message.payload || {}); + if (!activeJobIds.has(jobId) || canceledJobIds.has(jobId)) { + activeJobIds.delete(jobId); + canceledJobIds.delete(jobId); + return; + } + activeJobIds.delete(jobId); + if (result?.positions instanceof Float32Array) { + self.postMessage( + { + type: "layout-result", + jobId, + result, + }, + [result.positions.buffer], + ); + return; + } + + self.postMessage({ + type: "layout-result", + jobId, + result, + }); + } catch (error) { + if (!activeJobIds.has(jobId) || canceledJobIds.has(jobId)) { + activeJobIds.delete(jobId); + canceledJobIds.delete(jobId); + return; + } + activeJobIds.delete(jobId); + self.postMessage({ + type: "layout-result", + jobId, + result: fallbackErrorResult(error?.message || String(error)), + }); + } +}); diff --git a/ui/graph-native-bridge.js b/ui/graph-native-bridge.js new file mode 100644 index 0000000..ccb4bdd --- /dev/null +++ b/ui/graph-native-bridge.js @@ -0,0 +1,246 @@ +const DEFAULT_NATIVE_RUNTIME_OPTIONS = Object.freeze({ + graphUseNativeLayout: false, + graphNativeLayoutThresholdNodes: 280, + graphNativeLayoutThresholdEdges: 1600, + graphNativeLayoutWorkerTimeoutMs: 260, + nativeEngineFailOpen: true, + graphNativeForceDisable: false, +}); + +function clampPositiveInt(value, fallback, { min = 1, max = 120000 } = {}) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.max(min, Math.min(max, Math.floor(numeric))); +} + +function normalizeBoolean(value, fallback = false) { + if (typeof value === "boolean") return value; + if (value == null) return fallback; + if (typeof value === "number") return Number.isFinite(value) && value !== 0; + const normalized = String(value).trim().toLowerCase(); + if (!normalized) return fallback; + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return fallback; +} + +export function normalizeGraphNativeRuntimeOptions(options = {}) { + const source = + options && typeof options === "object" && !Array.isArray(options) + ? options + : {}; + return { + graphUseNativeLayout: normalizeBoolean( + source.graphUseNativeLayout, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphUseNativeLayout, + ), + graphNativeLayoutThresholdNodes: clampPositiveInt( + source.graphNativeLayoutThresholdNodes, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutThresholdNodes, + { min: 1, max: 20000 }, + ), + graphNativeLayoutThresholdEdges: clampPositiveInt( + source.graphNativeLayoutThresholdEdges, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutThresholdEdges, + { min: 1, max: 50000 }, + ), + graphNativeLayoutWorkerTimeoutMs: clampPositiveInt( + source.graphNativeLayoutWorkerTimeoutMs, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutWorkerTimeoutMs, + { min: 40, max: 15000 }, + ), + nativeEngineFailOpen: normalizeBoolean( + source.nativeEngineFailOpen, + DEFAULT_NATIVE_RUNTIME_OPTIONS.nativeEngineFailOpen, + ), + graphNativeForceDisable: normalizeBoolean( + source.graphNativeForceDisable, + DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeForceDisable, + ), + }; +} + +export class GraphNativeLayoutBridge { + constructor(runtimeOptions = {}) { + this.runtimeOptions = normalizeGraphNativeRuntimeOptions(runtimeOptions); + this._worker = null; + this._workerBootError = ""; + this._nextJobId = 1; + this._pendingJobs = new Map(); + this._isDisposed = false; + } + + updateRuntimeOptions(runtimeOptions = {}) { + this.runtimeOptions = normalizeGraphNativeRuntimeOptions(runtimeOptions); + } + + shouldRunForGraph(nodeCount = 0, edgeCount = 0) { + if (this.runtimeOptions.graphNativeForceDisable) return false; + if (!this.runtimeOptions.graphUseNativeLayout) return false; + const normalizedNodes = Math.max(0, Number(nodeCount) || 0); + const normalizedEdges = Math.max(0, Number(edgeCount) || 0); + return ( + normalizedNodes >= this.runtimeOptions.graphNativeLayoutThresholdNodes || + normalizedEdges >= this.runtimeOptions.graphNativeLayoutThresholdEdges + ); + } + + async solveLayout(payload = {}, { timeoutMs = null } = {}) { + if (this._isDisposed) { + return { + ok: false, + skipped: true, + reason: "bridge-disposed", + }; + } + + const worker = this._ensureWorker(); + if (!worker) { + const result = { + ok: false, + skipped: true, + reason: "worker-unavailable", + error: this._workerBootError || "Graph worker unavailable", + }; + if (this.runtimeOptions.nativeEngineFailOpen) { + return result; + } + throw new Error(result.error); + } + + const normalizedTimeoutMs = clampPositiveInt( + timeoutMs, + this.runtimeOptions.graphNativeLayoutWorkerTimeoutMs, + { min: 40, max: 15000 }, + ); + + const jobId = this._nextJobId++; + return await new Promise((resolve) => { + const timer = setTimeout(() => { + if (this._worker) { + this._worker.postMessage({ + type: "cancel-layout", + jobId, + reason: "native-layout-timeout", + }); + } + this._pendingJobs.delete(jobId); + resolve({ + ok: false, + skipped: true, + reason: "native-layout-timeout", + timeoutMs: normalizedTimeoutMs, + }); + }, normalizedTimeoutMs); + + this._pendingJobs.set(jobId, { + resolve, + timer, + }); + + worker.postMessage({ + type: "solve-layout", + jobId, + payload: { + ...payload, + nativeRequested: true, + timeoutMs: normalizedTimeoutMs, + }, + }); + }); + } + + cancelPending(reason = "native-layout-canceled") { + if (this._pendingJobs.size <= 0) return; + const cancelReason = String(reason || "native-layout-canceled").trim() || + "native-layout-canceled"; + for (const [jobId, pending] of this._pendingJobs.entries()) { + clearTimeout(pending.timer); + pending.resolve({ + ok: false, + skipped: true, + reason: cancelReason, + }); + if (this._worker) { + this._worker.postMessage({ + type: "cancel-layout", + jobId, + reason: cancelReason, + }); + } + } + this._pendingJobs.clear(); + } + + dispose() { + if (this._isDisposed) return; + this._isDisposed = true; + this.cancelPending("bridge-disposed"); + if (this._worker) { + this._worker.terminate(); + this._worker = null; + } + } + + _ensureWorker() { + if (this._worker) return this._worker; + if (this._isDisposed) return null; + if (typeof Worker !== "function") { + this._workerBootError = "Worker API unavailable"; + return null; + } + + try { + const worker = new Worker(new URL("./graph-layout-worker.js", import.meta.url), { + type: "module", + }); + worker.addEventListener("message", (event) => { + this._handleWorkerMessage(event?.data || {}); + }); + worker.addEventListener("error", (event) => { + const message = + String(event?.message || "").trim() || "Graph layout worker error"; + this._workerBootError = message; + this._resolveAllPending({ + ok: false, + skipped: true, + reason: "native-worker-error", + error: message, + }); + }); + this._worker = worker; + return worker; + } catch (error) { + this._workerBootError = error?.message || String(error); + return null; + } + } + + _handleWorkerMessage(data = {}) { + if (!data || data.type !== "layout-result") return; + const jobId = Number(data.jobId); + if (!Number.isFinite(jobId)) return; + const pending = this._pendingJobs.get(jobId); + if (!pending) return; + this._pendingJobs.delete(jobId); + clearTimeout(pending.timer); + + const result = data.result || {}; + if (result?.positions && !(result.positions instanceof Float32Array)) { + try { + result.positions = Float32Array.from(result.positions); + } catch { + result.positions = null; + } + } + pending.resolve(result); + } + + _resolveAllPending(result = {}) { + for (const pending of this._pendingJobs.values()) { + clearTimeout(pending.timer); + pending.resolve(result); + } + this._pendingJobs.clear(); + } +} diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 005b5a6..1a11e27 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -12,6 +12,10 @@ import { aliasSetMatchesValue, buildUserPovAliasNormalizedSet, } from '../runtime/user-alias-utils.js'; +import { + GraphNativeLayoutBridge, + normalizeGraphNativeRuntimeOptions, +} from './graph-native-bridge.js'; /** * @typedef {Object} GraphNode @@ -56,6 +60,39 @@ const ADAPTIVE_NEURAL_LAYOUT_POLICY = Object.freeze({ }); const MIN_USABLE_CANVAS_DIMENSION = 48; +const RUNTIME_DEBUG_STATE_KEY = '__stBmeRuntimeDebugState'; + +function cloneGraphLayoutDebugValue(value, fallback = null) { + if (value == null) return fallback; + if (typeof globalThis.structuredClone === 'function') { + try { + return globalThis.structuredClone(value); + } catch {} + } + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return fallback; + } +} + +function recordGraphLayoutDebugSnapshot(snapshot = null) { + if (!globalThis || typeof globalThis !== 'object') return; + if (!globalThis[RUNTIME_DEBUG_STATE_KEY] || typeof globalThis[RUNTIME_DEBUG_STATE_KEY] !== 'object') { + globalThis[RUNTIME_DEBUG_STATE_KEY] = { + updatedAt: '', + graphLayout: null, + }; + } + const state = globalThis[RUNTIME_DEBUG_STATE_KEY]; + state.graphLayout = snapshot && typeof snapshot === 'object' + ? { + updatedAt: new Date().toISOString(), + ...cloneGraphLayoutDebugValue(snapshot, {}), + } + : null; + state.updatedAt = new Date().toISOString(); +} /** 兼容旧版 forceConfig(召回卡片等) */ function layoutKeysFromForceConfig(fc) { @@ -194,6 +231,7 @@ export class GraphRenderer { const themeName = isLegacy ? options : (options?.theme || 'crimson'); const layoutOverride = isLegacy ? {} : (options?.layoutConfig || {}); const fromForce = isLegacy ? {} : layoutKeysFromForceConfig(options?.forceConfig); + const runtimeConfig = isLegacy ? {} : (options?.runtimeConfig || {}); this.canvas = canvas; this.ctx = canvas.getContext('2d'); @@ -203,9 +241,13 @@ export class GraphRenderer { this.colors = getNodeColors(themeName); this.themeName = themeName; this.config = { ...DEFAULT_LAYOUT_CONFIG, ...fromForce, ...layoutOverride }; + this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig); this._userPovAliasSet = buildUserPovAliasNormalizedSet( isLegacy ? null : options?.userPovAliases, ); + this._nativeLayoutBridge = null; + this._layoutSolveRevision = 0; + this._lastLayoutDiagnostics = null; this._regionPanels = []; this._lastGraph = null; @@ -253,7 +295,10 @@ export class GraphRenderer { * @param {{ userPovAliases?: string|string[]|object }} [layoutHints] */ loadGraph(graph, layoutHints = {}) { + const loadStartedAt = performance.now(); const prevSelectedId = this.selectedNode?.id || null; + const solveRevision = this._nextLayoutSolveRevision(); + this._nativeLayoutBridge?.cancelPending?.('graph-load-replaced'); this._lastGraph = graph; this._lastLayoutHints = layoutHints && typeof layoutHints === 'object' ? { ...layoutHints } @@ -303,13 +348,39 @@ export class GraphRenderer { strength: e.strength || 0.5, relation: e.relation || 'related', })); + const prepareFinishedAt = performance.now(); const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet); this._regionPanels = this._computeRegionPanels(W, H, parts); this._layoutAllPartitions(parts); + const layoutFinishedAt = performance.now(); const neuralPlan = this._resolveNeuralSimulationPlan(); + const shouldTryNativeLayout = this._shouldTryNativeLayout( + this.nodes.length, + this.edges.length, + ); + + let solvePath = neuralPlan.skip ? 'skipped' : 'js-main'; + let solveMs = 0; + let nativeSolvePromise = null; + if (!neuralPlan.skip && neuralPlan.iterations > 0) { - this._simulateNeuralWithinRegions(neuralPlan.iterations); + if (shouldTryNativeLayout) { + solvePath = 'native-worker-pending'; + nativeSolvePromise = this._simulateNeuralWithNativeBridge( + neuralPlan.iterations, + solveRevision, + { + loadStartedAt, + prepareFinishedAt, + layoutFinishedAt, + }, + ); + } else { + const solveStartedAt = performance.now(); + this._simulateNeuralWithinRegions(neuralPlan.iterations); + solveMs = Math.max(0, performance.now() - solveStartedAt); + } } if (prevSelectedId) { @@ -318,6 +389,35 @@ export class GraphRenderer { this._cancelAnim(); this._render(); + + if (!nativeSolvePromise) { + this._setLastLayoutDiagnostics({ + mode: solvePath, + nodeCount: this.nodes.length, + edgeCount: this.edges.length, + prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), + layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), + solveMs, + totalMs: Math.max(0, performance.now() - loadStartedAt), + at: Date.now(), + }); + return; + } + + nativeSolvePromise + .then((result) => { + if (!result) return; + this._setLastLayoutDiagnostics({ + ...result.diagnostics, + at: Date.now(), + }); + if (result.applied && this.enabled) { + this._scheduleRender(); + } + }) + .catch(() => { + // fail-open 路径由 bridge 内部控制 + }); } /** @@ -329,6 +429,33 @@ export class GraphRenderer { if (this.enabled) this._render(); } + setRuntimeConfig(runtimeConfig = {}) { + this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig); + if (this._nativeLayoutBridge) { + this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig); + } + } + + getLastLayoutDiagnostics() { + return this._lastLayoutDiagnostics + ? { ...this._lastLayoutDiagnostics } + : null; + } + + _setLastLayoutDiagnostics(diagnostics = null) { + this._lastLayoutDiagnostics = diagnostics && typeof diagnostics === 'object' + ? { ...diagnostics } + : null; + recordGraphLayoutDebugSnapshot( + this._lastLayoutDiagnostics + ? { + ...this._lastLayoutDiagnostics, + enabled: this.enabled !== false, + } + : null, + ); + } + /** * 高亮指定节点 */ @@ -351,7 +478,12 @@ export class GraphRenderer { if (!nextEnabled) this._clearCanvas(); return; } + this._nextLayoutSolveRevision(); + this._nativeLayoutBridge?.cancelPending?.('graph-renderer-state-changed'); this.enabled = nextEnabled; + if (this._lastLayoutDiagnostics) { + this._setLastLayoutDiagnostics(this._lastLayoutDiagnostics); + } this._cancelAnim(); this.dragNode = null; this.isDragging = false; @@ -630,6 +762,190 @@ export class GraphRenderer { }; } + _nextLayoutSolveRevision() { + this._layoutSolveRevision = Math.max(1, Number(this._layoutSolveRevision || 0) + 1); + return this._layoutSolveRevision; + } + + _ensureNativeLayoutBridge() { + if (this._nativeLayoutBridge) { + this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig); + return this._nativeLayoutBridge; + } + this._nativeLayoutBridge = new GraphNativeLayoutBridge(this.runtimeConfig); + return this._nativeLayoutBridge; + } + + _shouldTryNativeLayout(nodeCount = 0, edgeCount = 0) { + if (this.runtimeConfig.graphNativeForceDisable) return false; + if (!this.runtimeConfig.graphUseNativeLayout) return false; + const bridge = this._ensureNativeLayoutBridge(); + if (!bridge) return false; + return bridge.shouldRunForGraph(nodeCount, edgeCount); + } + + _buildNativeLayoutPayload(iterations) { + const nodeIndexById = new Map(); + const nodes = this.nodes.map((node, index) => { + nodeIndexById.set(node.id, index); + return { + x: node.x, + y: node.y, + vx: node.vx, + vy: node.vy, + pinned: node.pinned === true, + radius: this._nodeRadius(node), + regionKey: node.regionKey, + regionRect: node.regionRect + ? { + x: node.regionRect.x, + y: node.regionRect.y, + w: node.regionRect.w, + h: node.regionRect.h, + } + : null, + }; + }); + + const edges = this.edges + .map((edge) => { + const from = nodeIndexById.get(edge.from?.id); + const to = nodeIndexById.get(edge.to?.id); + if (!Number.isFinite(from) || !Number.isFinite(to) || from === to) { + return null; + } + return { + from, + to, + strength: edge.strength || 0.5, + }; + }) + .filter(Boolean); + + return { + nodes, + edges, + config: { + iterations, + repulsion: this.config.neuralRepulsion ?? 2800, + springK: this.config.neuralSpringK ?? 0.048, + damping: this.config.neuralDamping ?? 0.88, + centerGravity: this.config.neuralCenterGravity ?? 0.014, + minGap: this.config.neuralMinGap ?? 12, + speedCap: 3.8, + }, + }; + } + + _applyLayoutPositions(positions) { + if (!(positions instanceof Float32Array)) return false; + if (positions.length < this.nodes.length * 2) return false; + + for (let i = 0; i < this.nodes.length; i++) { + const node = this.nodes[i]; + if (!node || node.pinned) continue; + node.x = positions[i * 2]; + node.y = positions[i * 2 + 1]; + node.vx = 0; + node.vy = 0; + this._clampNodeToRegion(node); + } + return true; + } + + async _simulateNeuralWithNativeBridge(iterations, solveRevision, timings = {}) { + const loadStartedAt = Number(timings.loadStartedAt) || performance.now(); + const prepareFinishedAt = Number(timings.prepareFinishedAt) || loadStartedAt; + const layoutFinishedAt = Number(timings.layoutFinishedAt) || prepareFinishedAt; + + const bridge = this._ensureNativeLayoutBridge(); + const solveStartedAt = performance.now(); + let nativeResult = null; + + try { + nativeResult = await bridge.solveLayout(this._buildNativeLayoutPayload(iterations), { + timeoutMs: this.runtimeConfig.graphNativeLayoutWorkerTimeoutMs, + }); + } catch (error) { + nativeResult = { + ok: false, + skipped: true, + reason: 'native-layout-bridge-error', + error: error?.message || String(error), + }; + } + + if (solveRevision !== this._layoutSolveRevision) { + return { + applied: false, + diagnostics: { + mode: 'native-stale', + nodeCount: this.nodes.length, + edgeCount: this.edges.length, + prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), + layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), + solveMs: Math.max(0, performance.now() - solveStartedAt), + totalMs: Math.max(0, performance.now() - loadStartedAt), + reason: 'stale-layout-result', + }, + }; + } + + if (nativeResult?.ok && this._applyLayoutPositions(nativeResult.positions)) { + const workerElapsedMs = Number(nativeResult?.diagnostics?.elapsedMs); + return { + applied: true, + diagnostics: { + mode: nativeResult.usedNative ? 'rust-wasm-worker' : 'js-worker', + nodeCount: this.nodes.length, + edgeCount: this.edges.length, + prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), + layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), + solveMs: Math.max(0, performance.now() - solveStartedAt), + workerSolveMs: Number.isFinite(workerElapsedMs) + ? Math.max(0, workerElapsedMs) + : 0, + totalMs: Math.max(0, performance.now() - loadStartedAt), + reason: '', + }, + }; + } + + if (!this.runtimeConfig.nativeEngineFailOpen) { + return { + applied: false, + diagnostics: { + mode: 'native-failed-hard', + nodeCount: this.nodes.length, + edgeCount: this.edges.length, + prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), + layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), + solveMs: Math.max(0, performance.now() - solveStartedAt), + totalMs: Math.max(0, performance.now() - loadStartedAt), + reason: nativeResult?.reason || 'native-layout-failed', + }, + }; + } + + const fallbackStartedAt = performance.now(); + this._simulateNeuralWithinRegions(iterations); + const fallbackSolveMs = Math.max(0, performance.now() - fallbackStartedAt); + return { + applied: true, + diagnostics: { + mode: 'js-fallback', + nodeCount: this.nodes.length, + edgeCount: this.edges.length, + prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt), + layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), + solveMs: Math.max(0, performance.now() - solveStartedAt) + fallbackSolveMs, + fallbackSolveMs, + totalMs: Math.max(0, performance.now() - loadStartedAt), + reason: nativeResult?.reason || 'native-layout-failed', + }, + }; + } + /** * 分区内一次性力导向:斥力 + 同区边弹簧 + 弱向心,稳定后停止(无帧循环) */ @@ -1171,6 +1487,8 @@ export class GraphRenderer { } if (this.nodes.length > 0 && this._regionPanels.length > 0) { + this._nextLayoutSolveRevision(); + this._nativeLayoutBridge?.cancelPending?.('viewport-resize-layout-reset'); this._rebuildLayoutForCurrentViewport(w, h); this._render(); } else if (this._lastGraph) { @@ -1181,7 +1499,22 @@ export class GraphRenderer { } destroy() { + this._nextLayoutSolveRevision(); this._cancelAnim(); + this._nativeLayoutBridge?.dispose?.(); + this._nativeLayoutBridge = null; + recordGraphLayoutDebugSnapshot( + this._lastLayoutDiagnostics + ? { + ...this._lastLayoutDiagnostics, + enabled: false, + destroyed: true, + } + : { + enabled: false, + destroyed: true, + }, + ); this._resizeObserver?.disconnect(); } } diff --git a/ui/panel.html b/ui/panel.html index 04c0d07..018a8e7 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -534,6 +534,7 @@
READY NODES: 0 | EDGES: 0 + LAYOUT: --
@@ -643,6 +644,7 @@ >READY NODES: 0 | EDGES: 0 + LAYOUT: -- diff --git a/ui/panel.js b/ui/panel.js index 26cbdb9..259d3f3 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -737,6 +737,36 @@ function _isGraphRenderingEnabled() { return graphRenderingEnabled !== false; } +function _buildGraphRuntimeConfig(settings = _getSettings?.() || {}) { + return { + graphUseNativeLayout: settings.graphUseNativeLayout === true, + graphNativeLayoutThresholdNodes: Number.isFinite( + Number(settings.graphNativeLayoutThresholdNodes), + ) + ? Math.max(1, Math.floor(Number(settings.graphNativeLayoutThresholdNodes))) + : 280, + graphNativeLayoutThresholdEdges: Number.isFinite( + Number(settings.graphNativeLayoutThresholdEdges), + ) + ? Math.max(1, Math.floor(Number(settings.graphNativeLayoutThresholdEdges))) + : 1600, + graphNativeLayoutWorkerTimeoutMs: Number.isFinite( + Number(settings.graphNativeLayoutWorkerTimeoutMs), + ) + ? Math.max(40, Math.floor(Number(settings.graphNativeLayoutWorkerTimeoutMs))) + : 260, + nativeEngineFailOpen: settings.nativeEngineFailOpen !== false, + graphNativeForceDisable: settings.graphNativeForceDisable === true, + }; +} + +function _applyGraphRuntimeConfig(settings = _getSettings?.() || {}) { + const runtimeConfig = _buildGraphRuntimeConfig(settings); + graphRenderer?.setRuntimeConfig?.(runtimeConfig); + mobileGraphRenderer?.setRuntimeConfig?.(runtimeConfig); + return runtimeConfig; +} + function _refreshGraphRenderToggleUi() { const enabled = _isGraphRenderingEnabled(); const syncButton = (button) => { @@ -779,6 +809,7 @@ function _toggleGraphRenderingEnabled() { function _refreshVisibleGraphWorkspace({ force = false } = {}) { const visibleMode = _getVisibleGraphWorkspaceMode(); if (visibleMode === "hidden") { + _refreshGraphLayoutDiagnosticsUi(); return { refreshed: false, reason: "hidden" }; } @@ -808,6 +839,8 @@ function _refreshVisibleGraphWorkspace({ force = false } = {}) { _refreshMobileSummaryFull(); } + _refreshGraphLayoutDiagnosticsUi(); + lastVisibleGraphRefreshToken = nextToken; lastVisibleGraphRefreshAt = Date.now(); return { @@ -1159,6 +1192,7 @@ export function openPanel() { const graphOpts = { theme: themeName, userPovAliases: _hostUserPovAliasHintsForGraph(), + runtimeConfig: _buildGraphRuntimeConfig(settings), }; const canvas = document.getElementById("bme-graph-canvas"); if (canvas && !graphRenderer && !isMobile) { @@ -1172,6 +1206,8 @@ export function openPanel() { mobileGraphRenderer.onNodeSelect = (node) => _showNodeDetail(node); } + _applyGraphRuntimeConfig(settings); + _applyGraphRenderEnabledState(); const activeTabId = @@ -1204,6 +1240,7 @@ export function updatePanelTheme(themeName) { export function refreshLiveState() { if (!overlayEl?.classList.contains("active")) return; + _applyGraphRuntimeConfig(_getSettings?.() || {}); _refreshRuntimeStatus(); switch (currentTabId) { @@ -3982,6 +4019,89 @@ function _getActiveGraphRenderer() { return mobileGraphRenderer || graphRenderer; } +function _resolveVisibleGraphRenderer() { + const visibleMode = _getVisibleGraphWorkspaceMode(); + if (visibleMode.startsWith("mobile:")) { + return mobileGraphRenderer || graphRenderer; + } + if (visibleMode.startsWith("desktop:")) { + return graphRenderer || mobileGraphRenderer; + } + return _getActiveGraphRenderer(); +} + +function _formatGraphLayoutDiagnosticsText(diagnostics = null) { + if (!diagnostics || typeof diagnostics !== "object") { + return "LAYOUT: --"; + } + + const modeRaw = String( + diagnostics.mode || diagnostics.solver || "", + ).trim(); + const modeMap = { + "js-main": "JS-main", + "js-worker": "JS-worker", + "rust-wasm-worker": "Rust-WASM", + "js-fallback": "JS-fallback", + skipped: "skipped", + "native-stale": "stale", + "native-failed-hard": "native-failed", + }; + const modeLabel = modeMap[modeRaw] || modeRaw || "unknown"; + + const totalMs = Number( + diagnostics.totalMs ?? diagnostics.solveMs ?? diagnostics.workerSolveMs, + ); + const nodeCount = Number(diagnostics.nodeCount); + const edgeCount = Number(diagnostics.edgeCount); + + const parts = [`LAYOUT: ${modeLabel}`]; + if (Number.isFinite(totalMs)) { + parts.push(`${Math.max(0, Math.round(totalMs))}ms`); + } + if (Number.isFinite(nodeCount) && Number.isFinite(edgeCount)) { + parts.push( + `${Math.max(0, Math.floor(nodeCount))}/${Math.max( + 0, + Math.floor(edgeCount), + )}`, + ); + } + + return parts.join(" · "); +} + +function _refreshGraphLayoutDiagnosticsUi() { + const desktopMeta = document.getElementById("bme-graph-layout-meta"); + const mobileMeta = document.getElementById("bme-mobile-graph-layout-meta"); + if (!desktopMeta && !mobileMeta) return; + + const renderer = _resolveVisibleGraphRenderer(); + const diagnostics = renderer?.getLastLayoutDiagnostics?.() || null; + const text = _formatGraphLayoutDiagnosticsText(diagnostics); + const title = diagnostics?.reason + ? `layout reason: ${String(diagnostics.reason).trim()}` + : ""; + + if (desktopMeta) { + desktopMeta.textContent = text; + if (title) { + desktopMeta.title = title; + } else { + desktopMeta.removeAttribute("title"); + } + } + + if (mobileMeta) { + mobileMeta.textContent = text; + if (title) { + mobileMeta.title = title; + } else { + mobileMeta.removeAttribute("title"); + } + } +} + function _bindGraphControls() { document .getElementById("bme-graph-render-toggle") @@ -7290,6 +7410,8 @@ function _getMessageTraceWorkspaceState(settings = _getSettings?.() || {}) { panelDebug, runtimeDebug, recallInjection: runtimeDebug?.injections?.recall || null, + graphLayout: runtimeDebug?.graphLayout || null, + persistDelta: runtimeDebug?.graphPersistence?.persistDelta || null, messageTrace: runtimeDebug?.messageTrace || null, recallLlmRequest: runtimeDebug?.taskLlmRequests?.recall || null, recallPromptBuild: runtimeDebug?.taskPromptBuilds?.recall || null, @@ -7313,6 +7435,8 @@ function _refreshMessageTraceWorkspace(settings = _getSettings?.() || {}) { function _renderMessageTraceWorkspace(state) { const updatedCandidates = [ state.recallInjection?.updatedAt, + state.graphLayout?.updatedAt, + state.persistDelta?.updatedAt, state.recallLlmRequest?.updatedAt, state.extractLlmRequest?.updatedAt, state.extractPromptBuild?.updatedAt, @@ -7345,6 +7469,12 @@ function _renderMessageTraceWorkspace(state) {
${_renderAiMonitorCognitionCard(state)}
+
+ ${_renderGraphLayoutTraceCard(state)} +
+
+ ${_renderPersistDeltaTraceCard(state)} +
`; @@ -7808,6 +7938,180 @@ function _renderAiMonitorCognitionCard(state) { `; } +function _renderGraphLayoutTraceCard(state) { + const layout = state.graphLayout || null; + if (!layout) { + return ` +
图布局 / Native 诊断
+
+ 还没有图布局诊断快照。打开图谱页并触发一次布局后,这里会显示实际执行路径、耗时和 native 模块来源。 +
+ `; + } + + const mode = String(layout.mode || layout.solver || 'unknown').trim() || 'unknown'; + const moduleSource = String(layout.moduleSource || '').trim() || '—'; + const reason = String(layout.reason || '').trim() || '—'; + const nativeLoadError = String(layout.nativeLoadError || '').trim(); + + return ` +
+
+
图布局 / Native 诊断
+
+ 记录最近一次图布局走了哪条路径,以及 native 模块是 wasm-pack 产物还是 fallback loader。 +
+
+ ${_escHtml(_formatTaskProfileTime(layout.updatedAt || layout.at))} +
+
+
+ 布局路径 + ${_escHtml(mode)} +
+
+ 节点 / 边 + ${_escHtml(`${Number(layout.nodeCount || 0)} / ${Number(layout.edgeCount || 0)}`)} +
+
+ 总耗时 + ${_escHtml(_formatDurationMs(layout.totalMs))} +
+
+ 求解耗时 + ${_escHtml(_formatDurationMs(layout.solveMs || layout.workerSolveMs))} +
+
+ 迭代次数 + ${_escHtml(String(layout.iterations || '—'))} +
+
+ Native 来源 + ${_escHtml(moduleSource)} +
+
+ 状态原因 + ${_escHtml(reason)} +
+
+ ${_renderMessageTraceTextBlock( + 'Native load error', + nativeLoadError, + '当前没有 native load error。', + )} + `; +} + +function _formatPersistDeltaGateReasonText(reasons = []) { + const labels = { + "below-record-threshold": "记录数不足", + "below-structural-delta-threshold": "结构变化不足", + "below-serialized-chars-threshold": "序列化体积不足", + }; + const normalized = Array.isArray(reasons) + ? reasons + .map((item) => String(item || "").trim()) + .filter(Boolean) + : []; + if (!normalized.length) return "—"; + return normalized.map((item) => labels[item] || item).join(" · "); +} + +function _formatPersistDeltaGateText(diagnostics = null) { + if (!diagnostics || typeof diagnostics !== "object") return "—"; + if (diagnostics.requestedNative !== true) return "未请求 native"; + if (diagnostics.nativeForceDisabled === true) return "已强制关闭"; + if (diagnostics.gateAllowed === true) return "通过"; + return `已拦截 · ${_formatPersistDeltaGateReasonText(diagnostics.gateReasons)}`; +} + +function _renderPersistDeltaTraceCard(state) { + const diagnostics = state.persistDelta || null; + if (!diagnostics) { + return ` +
Persist Delta / Native 诊断
+
+ 还没有 persist delta 诊断快照。等图谱完成一次 IndexedDB 写回后,这里会显示 gate、执行路径、耗时和 fallback 原因。 +
+ `; + } + + const moduleSource = String(diagnostics.moduleSource || "").trim() || "—"; + const fallbackReason = String(diagnostics.fallbackReason || "").trim(); + const errorText = String( + diagnostics.moduleError || diagnostics.preloadError || diagnostics.nativeError || "", + ).trim(); + const payloadCharsText = diagnostics.combinedSerializedChars + ? `${Number(diagnostics.combinedSerializedChars || 0)} / ${Number(diagnostics.minCombinedSerializedChars || 0)}` + : "—"; + + return ` +
+
+
Persist Delta / Native 诊断
+
+ 记录最近一次图谱增量写回的 gate 判定、真实执行路径,以及 native preload / fallback 情况。 +
+
+ ${_escHtml(_formatTaskProfileTime(diagnostics.updatedAt))} +
+
+
+ 执行路径 + ${_escHtml(String(diagnostics.path || "—"))} +
+
+ Native Gate + ${_escHtml(_formatPersistDeltaGateText(diagnostics))} +
+
+ 快照记录数 + ${_escHtml(`${Number(diagnostics.beforeRecordCount || 0)} → ${Number(diagnostics.afterRecordCount || 0)}`)} +
+
+ 结构变化量 + ${_escHtml(String(diagnostics.structuralDelta ?? "—"))} +
+
+ Payload chars + ${_escHtml(payloadCharsText)} +
+
+ 总耗时 + ${_escHtml(_formatDurationMs(diagnostics.totalMs || diagnostics.buildMs))} +
+
+ 构建耗时 + ${_escHtml(_formatDurationMs(diagnostics.buildMs))} +
+
+ Preload + ${_escHtml(String(diagnostics.preloadStatus || "—"))} +
+
+ Native 来源 + ${_escHtml(moduleSource)} +
+
+ 增量规模 + ${_escHtml( + `${Number(diagnostics.upsertNodeCount || 0)}N / ${Number(diagnostics.upsertEdgeCount || 0)}E / ${Number(diagnostics.deleteNodeCount || 0)}DN / ${Number(diagnostics.deleteEdgeCount || 0)}DE`, + )} +
+
+ ${_renderMessageTraceTextBlock( + "Fallback reason", + fallbackReason, + "这次没有发生 native fallback。", + )} + ${_renderMessageTraceTextBlock( + "Preload / native error", + errorText, + "当前没有 preload / native error。", + )} + `; +} + function _renderMessageTraceTextBlock(title, text, emptyText = "暂无内容") { const normalized = String(text || "").trim(); return ` @@ -9016,6 +9320,8 @@ function _renderTaskDebugGraphPersistenceCard(graphPersistence) { `; } + const persistDelta = graphPersistence.persistDelta || null; + return `
@@ -9085,6 +9391,22 @@ function _renderTaskDebugGraphPersistenceCard(graphPersistence) { : "—", )}
+
+ Persist Delta 路径 + ${_escHtml(String(persistDelta?.path || "—"))} +
+
+ Persist Native Gate + ${_escHtml(_formatPersistDeltaGateText(persistDelta))} +
+
+ Persist Delta 耗时 + ${_escHtml(_formatDurationMs(persistDelta?.totalMs || persistDelta?.buildMs))} +
+
+ Persist Native 来源 + ${_escHtml(String(persistDelta?.moduleSource || "—"))} +
${_renderDebugDetails("图谱持久化详情", graphPersistence)} `; @@ -10887,6 +11209,7 @@ function _getGraphPersistenceSnapshot() { lastBackupRollbackAt: 0, lastBackupFilename: "", lastSyncError: "", + persistDelta: null, }; } @@ -11182,6 +11505,8 @@ function _refreshGraphAvailabilityState() { if (mobileOverlayText) { mobileOverlayText.textContent = overlayLabel; } + + _refreshGraphLayoutDiagnosticsUi(); } function _formatCloudTimeLabel(timestamp) { diff --git a/ui/ui-status.js b/ui/ui-status.js index 53e8df0..8884f2f 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -78,6 +78,7 @@ export function createGraphPersistenceState() { syncDirtyReason: "", lastSyncError: "", dualWriteLastResult: null, + persistDelta: null, updatedAt: new Date().toISOString(), }; } diff --git a/vendor/wasm/stbme_core.js b/vendor/wasm/stbme_core.js new file mode 100644 index 0000000..86e1e49 --- /dev/null +++ b/vendor/wasm/stbme_core.js @@ -0,0 +1,184 @@ +let cachedNativeModule = null; +let triedLoad = false; +let loadError = null; +let moduleSource = "none"; + +async function resolveWasmModuleInput(wasmUrl) { + if ( + wasmUrl && + typeof wasmUrl === "object" && + wasmUrl.protocol === "file:" && + typeof process === "object" && + process?.versions?.node + ) { + const { readFile } = await import("node:fs/promises"); + return readFile(wasmUrl); + } + return wasmUrl; +} + +async function initializeWasmModule(initFn, wasmUrl) { + if (typeof initFn !== "function") { + return; + } + + const moduleInput = await resolveWasmModuleInput(wasmUrl); + + try { + await initFn({ module_or_path: moduleInput }); + } catch (error) { + if ( + error && + typeof error.message === "string" && + /module_or_path|unexpected|invalid/i.test(error.message) + ) { + await initFn(moduleInput); + return; + } + throw error; + } +} + +async function loadFromWasmPackArtifacts() { + const module = await import("./pkg/stbme_core_pkg.js"); + if (!module || typeof module.solve_layout !== "function") { + throw new Error("invalid wasm-pack module shape"); + } + + const wasmUrl = new URL("./pkg/stbme_core_pkg_bg.wasm", import.meta.url); + if (typeof module.default === "function") { + await initializeWasmModule(module.default, wasmUrl); + } else if (typeof module.__wbg_init === "function") { + await initializeWasmModule(module.__wbg_init, wasmUrl); + } + + return { + solve_layout: module.solve_layout, + build_persist_delta_compact: + typeof module.build_persist_delta_compact === "function" + ? module.build_persist_delta_compact + : null, + build_persist_delta: + typeof module.build_persist_delta === "function" + ? module.build_persist_delta + : null, + }; +} + +async function loadNativeModule() { + if (cachedNativeModule) return cachedNativeModule; + if (triedLoad) { + throw loadError || new Error("stbme_core native module unavailable"); + } + + triedLoad = true; + + let wasmPackError = null; + if (globalThis.__stBmeDisableWasmPackArtifacts !== true) { + try { + const wasmPackModule = await loadFromWasmPackArtifacts(); + cachedNativeModule = wasmPackModule; + moduleSource = "wasm-pack-artifact"; + return cachedNativeModule; + } catch (error) { + wasmPackError = error instanceof Error ? error : new Error(String(error)); + } + } else { + wasmPackError = new Error("wasm-pack artifact loading disabled"); + } + + if (typeof globalThis.__stBmeLoadRustWasmLayout === "function") { + try { + const module = await globalThis.__stBmeLoadRustWasmLayout(); + if (module && typeof module.solve_layout === "function") { + cachedNativeModule = module; + moduleSource = "global-loader"; + return cachedNativeModule; + } + loadError = new Error("invalid native module shape"); + throw loadError; + } catch (error) { + loadError = error instanceof Error ? error : new Error(String(error)); + throw loadError; + } + } + + loadError = new Error( + [ + "Rust/WASM artifact is not initialized", + wasmPackError ? `wasm-pack load error: ${wasmPackError.message}` : "", + "define globalThis.__stBmeLoadRustWasmLayout for fallback injection", + ] + .filter(Boolean) + .join("; "), + ); + throw loadError; +} + +function toFloat32Array(value) { + if (value instanceof Float32Array) return value; + if (!Array.isArray(value)) return new Float32Array(0); + return Float32Array.from(value.map((item) => Number(item) || 0)); +} + +export async function solveLayout(payload) { + const module = await loadNativeModule(); + const raw = await module.solve_layout(payload); + const normalizedResult = raw && typeof raw === "object" ? raw : {}; + const positions = toFloat32Array(normalizedResult.positions); + return { + ok: normalizedResult.ok === true, + usedNative: true, + positions, + diagnostics: + normalizedResult.diagnostics && + typeof normalizedResult.diagnostics === "object" + ? normalizedResult.diagnostics + : { + solver: "rust-wasm", + nodeCount: Math.floor(positions.length / 2), + edgeCount: 0, + iterations: 0, + }, + }; +} + +export async function installNativePersistDeltaHook() { + const module = await loadNativeModule(); + if ( + !module || + (typeof module.build_persist_delta_compact !== "function" && + typeof module.build_persist_delta !== "function") + ) { + throw new Error("native persist delta builder unavailable"); + } + + globalThis.__stBmeNativeBuildPersistDelta = (beforeSnapshot, afterSnapshot, options = {}) => { + let raw = null; + if ( + typeof module.build_persist_delta_compact === "function" && + options?.preparedDeltaInput && + typeof options.preparedDeltaInput === "object" + ) { + raw = module.build_persist_delta_compact(options.preparedDeltaInput); + } else if (typeof module.build_persist_delta === "function") { + raw = module.build_persist_delta({ + beforeSnapshot, + afterSnapshot, + nowMs: options?.nowMs, + }); + } + return raw && typeof raw === "object" ? raw : null; + }; + + return getNativeModuleStatus(); +} + +export function getNativeModuleStatus() { + return { + loaded: Boolean(cachedNativeModule), + attempted: triedLoad, + source: moduleSource, + error: loadError?.message || "", + }; +}