mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
perf: optimize persist delta gating and diagnostics
This commit is contained in:
199
native/stbme-core/Cargo.lock
generated
Normal file
199
native/stbme-core/Cargo.lock
generated
Normal file
@@ -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"
|
||||
22
native/stbme-core/Cargo.toml
Normal file
22
native/stbme-core/Cargo.toml
Normal file
@@ -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"
|
||||
851
native/stbme-core/src/lib.rs
Normal file
851
native/stbme-core/src/lib.rs
Normal file
@@ -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<LayoutNode>,
|
||||
#[serde(default)]
|
||||
edges: Vec<LayoutEdge>,
|
||||
#[serde(default)]
|
||||
config: Option<LayoutConfig>,
|
||||
}
|
||||
|
||||
#[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<f32>,
|
||||
diagnostics: LayoutDiagnostics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistSnapshot {
|
||||
#[serde(default)]
|
||||
meta: JsonMap<String, JsonValue>,
|
||||
#[serde(default)]
|
||||
state: JsonMap<String, JsonValue>,
|
||||
#[serde(default)]
|
||||
nodes: Vec<JsonValue>,
|
||||
#[serde(default)]
|
||||
edges: Vec<JsonValue>,
|
||||
#[serde(default)]
|
||||
tombstones: Vec<JsonValue>,
|
||||
}
|
||||
|
||||
#[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<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistDeltaResult {
|
||||
upsert_nodes: Vec<JsonValue>,
|
||||
upsert_edges: Vec<JsonValue>,
|
||||
delete_node_ids: Vec<String>,
|
||||
delete_edge_ids: Vec<String>,
|
||||
tombstones: Vec<JsonValue>,
|
||||
runtime_meta_patch: JsonMap<String, JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistCompactRecordSet {
|
||||
#[serde(default)]
|
||||
ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
serialized: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistCompactTombstoneSet {
|
||||
#[serde(default)]
|
||||
ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
serialized: Vec<String>,
|
||||
#[serde(default)]
|
||||
target_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
upsert_edge_ids: Vec<String>,
|
||||
delete_node_ids: Vec<String>,
|
||||
delete_edge_ids: Vec<String>,
|
||||
upsert_tombstone_ids: Vec<String>,
|
||||
}
|
||||
|
||||
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::<f64>().ok().map(|item| item.floor() as i64).unwrap_or(fallback),
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_json_records(records: Vec<JsonValue>) -> Vec<JsonValue> {
|
||||
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<String, String> {
|
||||
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<String, JsonValue> {
|
||||
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<String, JsonValue> {
|
||||
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<String, JsonValue>,
|
||||
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<String, Vec<usize>> {
|
||||
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<String, f64> {
|
||||
let mut count_by_region: HashMap<String, usize> = HashMap::new();
|
||||
let mut area_by_region: HashMap<String, f64> = 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<JsValue, JsValue> {
|
||||
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<JsValue, JsValue> {
|
||||
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<JsValue, JsValue> {
|
||||
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}"))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user