{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[e4908910] consolidated\n\nSide A — unified diff (full patch):\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex 516cf935f15fca97081b39b988da5be894c67725..ceaa574cd32b7e7eb397c9a3191fcbc037b8c606 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -1,6 +1,5 @@\n use serde::{Deserialize, Serialize};\n \n-pub mod room_route;\n pub mod url_normalize;\n pub mod paths;\n pub mod timeago;\n@@ -8,9 +7,9 @@ pub mod timeago;\n pub use paths::{\n canonicalize_item, canonicalize_tag, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url,\n CanonicalItemUrl, ForumThreadUrl, GardenItemUrl, RelativePath, SLUG_TILDE_ONTOLOGY_ROOT,\n+ room_id_from_route_segment, room_route_segment, ROOM_SHORT_ID_LEN,\n TildeHttpPathTail, TildeOntologyPath, TildePath, tilde_http_path_to_canonical,\n };\n-pub use room_route::{room_id_from_route_segment, room_route_segment, ROOM_SHORT_ID_LEN};\n pub use url_normalize::normalize_http_identity_url;\n \n /// Max characters returned for a garden item body unless `full=true` / `--full` (API + CLI).\n@@ -661,6 +660,3 @@ pub struct VoteResponse {\n pub ranking: Vec,\n pub next: NextMoves,\n }\n-\n-#[cfg(test)]\n-mod url_identity_tests;\ndiff --git a/types/src/paths.rs b/types/src/paths.rs\nindex 98787a5fb481a1599556564bbbbd1d54dc693fc3..5c60e762c2ca74d79c74237097d5bcc02ba74af2 100644\n--- a/types/src/paths.rs\n+++ b/types/src/paths.rs\n@@ -8,6 +8,7 @@\n //! - **[`TildeHttpPathTail`]** — capture from `GET /~/*path` or `…/r/{short}{slug}/~/…` (the `*path` segment).\n //! - **`-/…` wire form** — external items; see [`canonicalize_item`] dash branch.\n //! - **[`GardenItemUrl`], [`ForumThreadUrl`]** — JSON / browser href surfaces.\n+//! - **[`ROOM_SHORT_ID_LEN`] / [`room_route_segment`]** — `/r/{short}{slug}` vs wire `short/slug`.\n \n use std::borrow::Borrow;\n use std::fmt;\n@@ -15,7 +16,6 @@ use std::ops::Deref;\n \n use serde::{Deserialize, Serialize};\n \n-use crate::room_route::room_route_segment;\n use crate::url_normalize::{host_preserves_dash_path_case, normalize_http_identity_url};\n \n // ---------------------------------------------------------------------------\n@@ -35,6 +35,46 @@ pub fn normalize_slug_ontology_storage_url(s: &str) -> String {\n }\n }\n \n+// ---------------------------------------------------------------------------\n+// Private room HTTP path (`/r/{short}{slug}`; wire id remains `short/slug`)\n+// ---------------------------------------------------------------------------\n+\n+/// Byte length of the random `short` segment in `short/slug` room ids (matches server `gen_short_id`).\n+pub const ROOM_SHORT_ID_LEN: usize = 7;\n+\n+/// `ab12cde/my-room` → `ab12cdemy-room` for a single `/r/…` path segment.\n+pub fn room_route_segment(room_id: &str) -> Option {\n+ let (short, slug) = room_id.split_once('/')?;\n+ if short.len() != ROOM_SHORT_ID_LEN || short.is_empty() || slug.is_empty() {\n+ return None;\n+ }\n+ if !short\n+ .bytes()\n+ .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z'))\n+ {\n+ return None;\n+ }\n+ Some(format!(\"{short}{slug}\"))\n+}\n+\n+/// `/r/{short}{slug}` path segment → `short/slug` wire id (inverse of [`room_route_segment`]).\n+pub fn room_id_from_route_segment(seg: &str) -> Option {\n+ if seg.len() <= ROOM_SHORT_ID_LEN {\n+ return None;\n+ }\n+ let (short, slug) = seg.split_at(ROOM_SHORT_ID_LEN);\n+ if short.is_empty() || slug.is_empty() {\n+ return None;\n+ }\n+ if !short\n+ .bytes()\n+ .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z'))\n+ {\n+ return None;\n+ }\n+ Some(format!(\"{short}/{slug}\"))\n+}\n+\n // ---------------------------------------------------------------------------\n // Normalization (moved from server `canonical_path`)\n // ---------------------------------------------------------------------------\n@@ -823,4 +863,17 @@ mod tests {\n Some(\"https://github.com/org/repo/issues\")\n );\n }\n+\n+ #[test]\n+ fn round_trip_room_segment() {\n+ let id = \"9ab12cd/my-room\";\n+ let seg = room_route_segment(id).unwrap();\n+ assert_eq!(seg, \"9ab12cdmy-room\");\n+ assert_eq!(room_id_from_route_segment(&seg).as_deref(), Some(id));\n+ }\n+\n+ #[test]\n+ fn too_short_room_route_segment_rejected() {\n+ assert!(room_id_from_route_segment(\"9ab12cd\").is_none());\n+ }\n }\ndiff --git a/types/src/room_route.rs b/types/src/room_route.rs\ndeleted file mode 100644\nindex 4f4780c88e30f2b28e2cfcd7aee713d39dbd1c66..0000000000000000000000000000000000000000\n--- a/types/src/room_route.rs\n+++ /dev/null\n@@ -1,56 +0,0 @@\n-//! HTTP path encoding for private rooms: `/r/{short}{slug}` (short is fixed width).\n-\n-/// Byte length of the random `short` segment in `short/slug` room ids.\n-/// Must match room creation (`gen_short_id`) and [`super::paths`][] URL builders.\n-pub const ROOM_SHORT_ID_LEN: usize = 7;\n-\n-/// `ab12cde/my-room` → `ab12cdemy-room` for a single `/r/…` path segment.\n-pub fn room_route_segment(room_id: &str) -> Option {\n- let (short, slug) = room_id.split_once('/')?;\n- if short.len() != ROOM_SHORT_ID_LEN || short.is_empty() || slug.is_empty() {\n- return None;\n- }\n- if !short\n- .bytes()\n- .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z'))\n- {\n- return None;\n- }\n- Some(format!(\"{short}{slug}\"))\n-}\n-\n-/// `/r/{short}{slug}` path segment → `short/slug` wire id (inverse of [`room_route_segment`]).\n-pub fn room_id_from_route_segment(seg: &str) -> Option {\n- if seg.len() <= ROOM_SHORT_ID_LEN {\n- return None;\n- }\n- let (short, slug) = seg.split_at(ROOM_SHORT_ID_LEN);\n- if short.is_empty() || slug.is_empty() {\n- return None;\n- }\n- if !short\n- .bytes()\n- .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z'))\n- {\n- return None;\n- }\n- Some(format!(\"{short}/{slug}\"))\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n-\n- #[test]\n- fn round_trip_room_segment() {\n- let id = \"9ab12cd/my-room\";\n- let seg = room_route_segment(id).unwrap();\n- assert_eq!(seg, \"9ab12cdmy-room\");\n- assert_eq!(room_id_from_route_segment(&seg).as_deref(), Some(id));\n- }\n-\n- #[test]\n- fn too_short_segment_rejected() {\n- assert!(room_id_from_route_segment(\"9ab12cd\").is_none());\n- }\n-}\ndiff --git a/types/src/url_identity_tests.rs b/types/src/url_identity_tests.rs\ndeleted file mode 100644\nindex e2ff0a9a64a88049e31f20979c703777df6a9b65..0000000000000000000000000000000000000000\n--- a/types/src/url_identity_tests.rs\n+++ /dev/null\n@@ -1,126 +0,0 @@\n-//! How `url::Url` behaves as `HashMap` keys (`Eq` + `Hash`).\n-//!\n-//! If `ItemId::External` stores `Url`, these tests are the contract you are buying into\n-//! (or the baseline before you add a custom normalization layer).\n-\n-use std::collections::HashMap;\n-use std::hash::{Hash, Hasher};\n-use url::Url;\n-\n-fn hash_one(url: &Url) -> u64 {\n- let mut h = std::collections::hash_map::DefaultHasher::new();\n- url.hash(&mut h);\n- h.finish()\n-}\n-\n-#[test]\n-fn identical_parse_strings_are_eq_and_share_hash_bucket() {\n- let a = Url::parse(\"https://example.com/path\").unwrap();\n- let b = Url::parse(\"https://example.com/path\").unwrap();\n- assert_eq!(a, b);\n- assert_eq!(hash_one(&a), hash_one(&b));\n-\n- let mut m: HashMap = HashMap::new();\n- m.insert(a, 1);\n- *m.entry(b).or_default() += 10;\n- assert_eq!(m.len(), 1);\n- assert_eq!(m[&Url::parse(\"https://example.com/path\").unwrap()], 11);\n-}\n-\n-#[test]\n-fn host_is_ascii_lowercase_in_eq() {\n- let lower = Url::parse(\"https://examplE.com/\").unwrap();\n- let upper = Url::parse(\"https://EXAMPLE.com/\").unwrap();\n- assert_eq!(lower, upper);\n- assert_eq!(hash_one(&lower), hash_one(&upper));\n-}\n-\n-#[test]\n-fn path_space_normalizes_to_percent_encoding_so_forms_merge() {\n- let encoded = Url::parse(\"https://example.com/a%20b\").unwrap();\n- let decoded = Url::parse(\"https://example.com/a b\").unwrap();\n- // Parser normalizes both to the same internal path (`/a%20b`).\n- assert_eq!(encoded, decoded);\n- assert_eq!(hash_one(&encoded), hash_one(&decoded));\n-\n- let mut m: HashMap = HashMap::new();\n- m.insert(encoded, \"first\");\n- assert_eq!(m.insert(decoded, \"second\"), Some(\"first\"));\n- assert_eq!(m.len(), 1);\n- assert_eq!(m.values().next().copied(), Some(\"second\"));\n-}\n-\n-#[test]\n-fn encoded_slash_in_segment_stays_distinct_from_real_path_separator() {\n- let encoded = Url::parse(\"https://example.com/a%2Fb\").unwrap();\n- let real_slash = Url::parse(\"https://example.com/a/b\").unwrap();\n- assert_ne!(encoded, real_slash);\n- assert_ne!(hash_one(&encoded), hash_one(&real_slash));\n-}\n-\n-#[test]\n-fn trailing_slash_on_path_is_significant_for_eq() {\n- let with_slash = Url::parse(\"https://example.com/foo/\").unwrap();\n- let no_slash = Url::parse(\"https://example.com/foo\").unwrap();\n- assert_ne!(with_slash, no_slash);\n- assert_ne!(hash_one(&with_slash), hash_one(&no_slash));\n-}\n-\n-#[test]\n-fn default_http_port_80_is_normalized_in_representation() {\n- let explicit = Url::parse(\"http://example.com:80/\").unwrap();\n- let implicit = Url::parse(\"http://example.com/\").unwrap();\n- assert_eq!(explicit, implicit);\n- assert_eq!(hash_one(&explicit), hash_one(&implicit));\n-}\n-\n-#[test]\n-fn default_https_port_443_is_normalized() {\n- let explicit = Url::parse(\"https://example.com:443/foo\").unwrap();\n- let implicit = Url::parse(\"https://example.com/foo\").unwrap();\n- assert_eq!(explicit, implicit);\n-}\n-\n-#[test]\n-fn non_default_port_is_part_of_identity() {\n- let a = Url::parse(\"https://example.com:444/\").unwrap();\n- let b = Url::parse(\"https://example.com:445/\").unwrap();\n- assert_ne!(a, b);\n-}\n-\n-#[test]\n-fn empty_path_vs_slash_only_path_may_differ() {\n- let root = Url::parse(\"https://example.com\").unwrap();\n- let slash = Url::parse(\"https://example.com/\").unwrap();\n- // Both serialize to `https://example.com/` in practice for this crate — verify.\n- assert_eq!(root, slash, \"document: root and trailing-slash-only merge for this parser\");\n-}\n-\n-#[test]\n-fn scheme_case_is_normalized_to_lowercase() {\n- let lower = Url::parse(\"https://example.com/\").unwrap();\n- let upper = Url::parse(\"HTTPS://example.com/\").unwrap();\n- assert_eq!(lower, upper);\n-}\n-\n-#[test]\n-fn fragment_is_part_of_eq_and_hash() {\n- let no_frag = Url::parse(\"https://example.com/a\").unwrap();\n- let frag = Url::parse(\"https://example.com/a#section\").unwrap();\n- assert_ne!(\n- no_frag, frag,\n- \"#fragment is included in PartialEq — anchors are different HashMap keys\"\n- );\n- assert_ne!(hash_one(&no_frag), hash_one(&frag));\n-}\n-\n-#[test]\n-fn query_order_and_encoding_can_split_identity() {\n- let a = Url::parse(\"https://example.com/?b=2&a=1\").unwrap();\n- let b = Url::parse(\"https://example.com/?a=1&b=2\").unwrap();\n- assert_ne!(a, b, \"query pairs order is preserved in serialization\");\n-\n- let plus = Url::parse(\"https://example.com/?q=a+b\").unwrap();\n- let encoded = Url::parse(\"https://example.com/?q=a%20b\").unwrap();\n- assert_ne!(plus, encoded, \"space as + vs %20 — different keys unless normalized\");\n-}\ndiff --git a/types/src/url_normalize.rs b/types/src/url_normalize.rs\nindex d5bc913f33ad29cd0cd1c7158e28e4fefdbf5f5d..999baa4f3367b632763b44ddc77b894451207c50 100644\n--- a/types/src/url_normalize.rs\n+++ b/types/src/url_normalize.rs\n@@ -232,3 +232,127 @@ mod tests {\n );\n }\n }\n+\n+/// `url::Url` as a `HashMap` key: `Eq` / `Hash` behavior (baseline if we store external ids as `Url`).\n+#[cfg(test)]\n+mod url_identity_tests {\n+ use std::collections::HashMap;\n+ use std::hash::{Hash, Hasher};\n+ use url::Url;\n+\n+ fn hash_one(url: &Url) -> u64 {\n+ let mut h = std::collections::hash_map::DefaultHasher::new();\n+ url.hash(&mut h);\n+ h.finish()\n+ }\n+\n+ #[test]\n+ fn identical_parse_strings_are_eq_and_share_hash_bucket() {\n+ let a = Url::parse(\"https://example.com/path\").unwrap();\n+ let b = Url::parse(\"https://example.com/path\").unwrap();\n+ assert_eq!(a, b);\n+ assert_eq!(hash_one(&a), hash_one(&b));\n+\n+ let mut m: HashMap = HashMap::new();\n+ m.insert(a, 1);\n+ *m.entry(b).or_default() += 10;\n+ assert_eq!(m.len(), 1);\n+ assert_eq!(m[&Url::parse(\"https://example.com/path\").unwrap()], 11);\n+ }\n+\n+ #[test]\n+ fn host_is_ascii_lowercase_in_eq() {\n+ let lower = Url::parse(\"https://examplE.com/\").unwrap();\n+ let upper = Url::parse(\"https://EXAMPLE.com/\").unwrap();\n+ assert_eq!(lower, upper);\n+ assert_eq!(hash_one(&lower), hash_one(&upper));\n+ }\n+\n+ #[test]\n+ fn path_space_normalizes_to_percent_encoding_so_forms_merge() {\n+ let encoded = Url::parse(\"https://example.com/a%20b\").unwrap();\n+ let decoded = Url::parse(\"https://example.com/a b\").unwrap();\n+ assert_eq!(encoded, decoded);\n+ assert_eq!(hash_one(&encoded), hash_one(&decoded));\n+\n+ let mut m: HashMap = HashMap::new();\n+ m.insert(encoded, \"first\");\n+ assert_eq!(m.insert(decoded, \"second\"), Some(\"first\"));\n+ assert_eq!(m.len(), 1);\n+ assert_eq!(m.values().next().copied(), Some(\"second\"));\n+ }\n+\n+ #[test]\n+ fn encoded_slash_in_segment_stays_distinct_from_real_path_separator() {\n+ let encoded = Url::parse(\"https://example.com/a%2Fb\").unwrap();\n+ let real_slash = Url::parse(\"https://example.com/a/b\").unwrap();\n+ assert_ne!(encoded, real_slash);\n+ assert_ne!(hash_one(&encoded), hash_one(&real_slash));\n+ }\n+\n+ #[test]\n+ fn trailing_slash_on_path_is_significant_for_eq() {\n+ let with_slash = Url::parse(\"https://example.com/foo/\").unwrap();\n+ let no_slash = Url::parse(\"https://example.com/foo\").unwrap();\n+ assert_ne!(with_slash, no_slash);\n+ assert_ne!(hash_one(&with_slash), hash_one(&no_slash));\n+ }\n+\n+ #[test]\n+ fn default_http_port_80_is_normalized_in_representation() {\n+ let explicit = Url::parse(\"http://example.com:80/\").unwrap();\n+ let implicit = Url::parse(\"http://example.com/\").unwrap();\n+ assert_eq!(explicit, implicit);\n+ assert_eq!(hash_one(&explicit), hash_one(&implicit));\n+ }\n+\n+ #[test]\n+ fn default_https_port_443_is_normalized() {\n+ let explicit = Url::parse(\"https://example.com:443/foo\").unwrap();\n+ let implicit = Url::parse(\"https://example.com/foo\").unwrap();\n+ assert_eq!(explicit, implicit);\n+ }\n+\n+ #[test]\n+ fn non_default_port_is_part_of_identity() {\n+ let a = Url::parse(\"https://example.com:444/\").unwrap();\n+ let b = Url::parse(\"https://example.com:445/\").unwrap();\n+ assert_ne!(a, b);\n+ }\n+\n+ #[test]\n+ fn empty_path_vs_slash_only_path_may_differ() {\n+ let root = Url::parse(\"https://example.com\").unwrap();\n+ let slash = Url::parse(\"https://example.com/\").unwrap();\n+ assert_eq!(root, slash, \"root and trailing-slash-only merge for this parser\");\n+ }\n+\n+ #[test]\n+ fn scheme_case_is_normalized_to_lowercase() {\n+ let lower = Url::parse(\"https://example.com/\").unwrap();\n+ let upper = Url::parse(\"HTTPS://example.com/\").unwrap();\n+ assert_eq!(lower, upper);\n+ }\n+\n+ #[test]\n+ fn fragment_is_part_of_eq_and_hash() {\n+ let no_frag = Url::parse(\"https://example.com/a\").unwrap();\n+ let frag = Url::parse(\"https://example.com/a#section\").unwrap();\n+ assert_ne!(\n+ no_frag, frag,\n+ \"#fragment is included in PartialEq — anchors are different HashMap keys\"\n+ );\n+ assert_ne!(hash_one(&no_frag), hash_one(&frag));\n+ }\n+\n+ #[test]\n+ fn query_order_and_encoding_can_split_identity() {\n+ let a = Url::parse(\"https://example.com/?b=2&a=1\").unwrap();\n+ let b = Url::parse(\"https://example.com/?a=1&b=2\").unwrap();\n+ assert_ne!(a, b, \"query pairs order is preserved in serialization\");\n+\n+ let plus = Url::parse(\"https://example.com/?q=a+b\").unwrap();\n+ let encoded = Url::parse(\"https://example.com/?q=a%20b\").unwrap();\n+ assert_ne!(plus, encoded, \"space as + vs %20 — different keys unless normalized\");\n+ }\n+}\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[e52a5895] Replace vestigial vote identity with pseudonym-based uuid dedup.\n\nStore votes in uuid_votes (not blind edge merges), derive rankings on load, and resolve actors via a pseudonyms map seeded at projection open.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs\nindex 3d988416ad36b27a7d3dc84280cfdbdcafa43e69..cc5cd78ba1e02a1e6f92aa503677f2d329bb5993 100644\n--- a/server/src/bin/storage_bench.rs\n+++ b/server/src/bin/storage_bench.rs\n@@ -29,14 +29,7 @@ async fn main() -> Result<(), Box> {\n for chunk_start in (0..opts.events).step_by(opts.batch_size) {\n let chunk_end = (chunk_start + opts.batch_size).min(opts.events);\n let events = (chunk_start..chunk_end)\n- .map(|i| Event::VoteRecorded {\n- ts: i as i64,\n- a: format!(\"item-{i}\"),\n- b: format!(\"item-{}\", i + 1),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- })\n+ .map(|i| Event::vote_recorded(i as i64, format!(\"item-{i}\"), format!(\"item-{}\", i + 1), 2, 1, \"\"))\n .collect();\n journal.append_many(events).await?;\n }\ndiff --git a/server/src/event_log.rs b/server/src/event_log.rs\nindex 2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36..8e5684cb1378e80c45bcf743d2fdc48402dd4dc8 100644\n--- a/server/src/event_log.rs\n+++ b/server/src/event_log.rs\n@@ -206,14 +206,7 @@ mod tests {\n .unwrap();\n log.append(&sample_record(\n 2,\n- Event::VoteRecorded {\n- ts: 1,\n- a: \"a\".into(),\n- b: \"b\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n+ Event::vote_recorded(1, \"a\", \"b\", 2, 1, \"\"),\n ))\n .await\n .unwrap();\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex d76c3bb4277216b0d39c9422ba7a50db10a95e05..8a166d49b4f26835fbc2b58cb1f4bdbf002763b8 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,7 +1,7 @@\n use serde::{Deserialize, Serialize};\n \n /// Schema version for JSONL log records. Bump when event semantics change.\n-pub const CURRENT_LOG_SCHEMA: u32 = 1;\n+pub const CURRENT_LOG_SCHEMA: u32 = 2;\n \n /// One JSONL line: schema envelope around a payload event.\n #[derive(Debug, Clone, Serialize, Deserialize)]\n@@ -51,9 +51,33 @@ pub enum Event {\n b: String,\n ratio_left: i32,\n ratio_right: i32,\n- #[serde(default)]\n scope: String,\n+ pseudonym: String,\n+ trust_weight: f64,\n },\n /// Register a node path in the fractal tree (no external fetch).\n NodeEnsured { id: String },\n }\n+\n+impl Event {\n+ /// Construct a vote event with the default dev pseudonym (tests and benches).\n+ pub fn vote_recorded(\n+ ts: i64,\n+ a: impl Into,\n+ b: impl Into,\n+ ratio_left: i32,\n+ ratio_right: i32,\n+ scope: impl Into,\n+ ) -> Self {\n+ Self::VoteRecorded {\n+ ts,\n+ a: a.into(),\n+ b: b.into(),\n+ ratio_left,\n+ ratio_right,\n+ scope: scope.into(),\n+ pseudonym: crate::identity::DEFAULT_PSEUDONYM.to_string(),\n+ trust_weight: 1.0,\n+ }\n+ }\n+}\ndiff --git a/server/src/html/vote.rs b/server/src/html/vote.rs\nindex 8cf2b0d4d8bd58cc51cc27c9046fa7f7728a7017..bf82aef3ad9c5f4e9e877c47dab27beb29a80b8f 100644\n--- a/server/src/html/vote.rs\n+++ b/server/src/html/vote.rs\n@@ -336,6 +336,7 @@ pub async fn vote_page(\n #[cfg(test)]\n mod polarity_tests {\n use super::*;\n+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};\n use crate::ranking::ranked_items;\n use crate::reducer::GlobalTree;\n \n@@ -351,11 +352,11 @@ mod polarity_tests {\n let right = id(\"right_item\");\n \n // Stored a == page left: keep order.\n- let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let v1 = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();\n assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));\n \n // Stored a == page right: swap so left stays left.\n- let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();\n+ let v2 = VoteData::from_event(2, right.as_str(), left.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();\n assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));\n }\n \n@@ -383,9 +384,9 @@ mod polarity_tests {\n let right = id(\"right_item\");\n \n // Slider dragged left yields e.g. 9:1 with a = left item.\n- let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();\n+ let vote = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();\n let mut tree = GlobalTree::new();\n- tree.apply_vote(&parent, vote);\n+ tree.apply_vote(&parent, vote, TEST_ACTOR_UUID);\n \n let group = &tree.get(&parent).unwrap().local_ranking;\n let ranked = ranked_items(group);\ndiff --git a/server/src/identity.rs b/server/src/identity.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..621d505436912246ec0830ff20c3473f0c4665ce\n--- /dev/null\n+++ b/server/src/identity.rs\n@@ -0,0 +1,35 @@\n+//! Actor identity: pseudonym display names mapped to stable UUIDs for vote dedup.\n+\n+use durable::Db;\n+\n+use crate::storage_schema::{Store, StoreFields};\n+\n+/// Default pseudonym until session/auth UI exists.\n+pub const DEFAULT_PSEUDONYM: &str = \"anon\";\n+\n+/// UUID for the default single-user dev principal.\n+pub const DEFAULT_ACTOR_UUID: &str = \"00000000-0000-0000-0000-000000000001\";\n+\n+/// UUID for in-memory unit tests.\n+pub const TEST_ACTOR_UUID: &str = \"00000000-0000-0000-0000-000000000099\";\n+\n+/// Resolve the trust anchor for a pseudonym (must exist in the pseudonyms map).\n+pub fn resolve_actor_uuid(db: &Db, pseudonym: &str) -> Result {\n+ Store::root()\n+ .pseudonyms()\n+ .key(&pseudonym.to_string())\n+ .get(db)\n+ .map_err(|e| e.to_string())?\n+ .ok_or_else(|| format!(\"unknown pseudonym: {pseudonym}\"))\n+}\n+\n+/// Ensure the default pseudonym → UUID mapping exists (operational seed, not event-logged).\n+pub fn seed_default_pseudonym(db: &Db) -> Result<(), durable::Error> {\n+ let path = Store::root()\n+ .pseudonyms()\n+ .key(&DEFAULT_PSEUDONYM.to_string());\n+ if path.get(db)?.is_none() {\n+ db.run(path.set(&DEFAULT_ACTOR_UUID.to_string()), durable::Durability::SyncWal)?;\n+ }\n+ Ok(())\n+}\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 3dfc7c8acb8ed61bb73ade63e72768e402042cc5..da6e33e3dd6d79967bbee7a2708a7d982c93b88c 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,6 +4,7 @@ pub mod events;\n pub mod fetch;\n pub mod form_template;\n pub mod html;\n+pub mod identity;\n pub mod journal;\n pub mod pair;\n pub mod parser;\ndiff --git a/server/src/pair.rs b/server/src/pair.rs\nindex 2277e32edf6e0024687d6fe2b9d1a9b84c0b34c8..42a1b1eb2adf16730d34d0fe23c13d5a75d7ba27 100644\n--- a/server/src/pair.rs\n+++ b/server/src/pair.rs\n@@ -360,8 +360,17 @@ impl PairError {\n #[cfg(test)]\n mod tests {\n use super::*;\n+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};\n use crate::reducer::{GlobalTree, VoteData};\n \n+ fn test_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+ VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap()\n+ }\n+\n+ fn apply(tree: &mut GlobalTree, parent: &ItemId, vote: VoteData) {\n+ tree.apply_vote(parent, vote, TEST_ACTOR_UUID);\n+ }\n+\n fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree {\n let mut tree = GlobalTree::new();\n tree.ensure_path(parent);\n@@ -382,22 +391,13 @@ mod tests {\n #[test]\n fn zero_weight_vote_leaves_pair_available_for_suggestion() {\n let parent = ItemId::parse(\"https://reddit.com/r/rust\").unwrap();\n- let mut tree = seed_children(\n+ let tree = seed_children(\n &parent,\n &[\n \"https://reddit.com/r/rust/a\",\n \"https://reddit.com/r/rust/b\",\n ],\n );\n- let noop = VoteData::from_recorded(\n- 1,\n- \"https://reddit.com/r/rust/a\",\n- \"https://reddit.com/r/rust/b\",\n- 0,\n- 0,\n- )\n- .unwrap();\n- tree.apply_vote(&parent, noop);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n assert!(!pair_is_voted(&group, &pool[0], &pool[1]));\n@@ -415,9 +415,8 @@ mod tests {\n \"https://reddit.com/r/rust/c\",\n ],\n );\n- let vote =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- tree.apply_vote(&parent, vote);\n+ let vote = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ apply(&mut tree, &parent, vote);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -438,12 +437,10 @@ mod tests {\n \"https://reddit.com/r/rust/d\",\n ],\n );\n- let ab =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- let cd =\n- VoteData::from_recorded(2, \"https://reddit.com/r/rust/c\", \"https://reddit.com/r/rust/d\", 2, 1).unwrap();\n- tree.apply_vote(&parent, ab);\n- tree.apply_vote(&parent, cd);\n+ let ab = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ let cd = test_vote(2, \"https://reddit.com/r/rust/c\", \"https://reddit.com/r/rust/d\", 2, 1);\n+ apply(&mut tree, &parent, ab);\n+ apply(&mut tree, &parent, cd);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -468,9 +465,8 @@ mod tests {\n \"https://reddit.com/r/rust/e\",\n ],\n );\n- let ab =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- tree.apply_vote(&parent, ab);\n+ let ab = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ apply(&mut tree, &parent, ab);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -498,9 +494,8 @@ mod tests {\n \"https://reddit.com/r/rust/c\",\n ],\n );\n- let ab =\n- VoteData::from_recorded(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1).unwrap();\n- tree.apply_vote(&parent, ab);\n+ let ab = test_vote(1, \"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 2, 1);\n+ apply(&mut tree, &parent, ab);\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();\n@@ -524,8 +519,8 @@ mod tests {\n (\"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/b\", 3, 1),\n (\"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/c\", 2, 1),\n ] {\n- let v = VoteData::from_recorded(1, a, b, l, r).unwrap();\n- tree.apply_vote(&parent, v);\n+ let v = test_vote(1, a, b, l, r);\n+ apply(&mut tree, &parent, v);\n }\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\n@@ -552,8 +547,8 @@ mod tests {\n (\"https://reddit.com/r/rust/b\", \"https://reddit.com/r/rust/c\", 2, 1),\n (\"https://reddit.com/r/rust/a\", \"https://reddit.com/r/rust/c\", 2, 1),\n ] {\n- let v = VoteData::from_recorded(1, a, b, l, r).unwrap();\n- tree.apply_vote(&parent, v);\n+ let v = test_vote(1, a, b, l, r);\n+ apply(&mut tree, &parent, v);\n }\n let group = tree.get(&parent).unwrap().local_ranking.clone();\n let pool = children_of(&tree, &parent);\ndiff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs\nindex ad404bacb8bcdd5ae0e682cff97f974fd44528ea..f2859c7e0e718c5bd3d332224eb05d7a8c65a816 100644\n--- a/server/src/projection_apply.rs\n+++ b/server/src/projection_apply.rs\n@@ -1,14 +1,13 @@\n //! Apply event-log records to the durable projection as precise point updates.\n //!\n-//! Each batch of records lowers to reified durable writes (edge merges, child\n-//! links, voted-pair flags, recent-vote pushes) plus a cursor advance, all\n-//! committed in one atomic `DisableWal` batch. The cursor moving in the same\n-//! batch as the (non-idempotent) edge merges guarantees exactly-once application\n-//! across replay.\n+//! Each batch of records lowers to reified durable writes (uuid vote upserts,\n+//! child links, recent-vote appends) plus a cursor advance, all committed in\n+//! one atomic `DisableWal` batch.\n \n use crate::{\n event_log::EventLogError,\n events::{Event, EventRecord},\n+ identity::resolve_actor_uuid,\n path_types::ItemId,\n projection_store::ProjectionStore,\n reducer::VoteData,\n@@ -53,20 +52,31 @@ pub fn apply_records(\n ratio_left,\n ratio_right,\n scope,\n+ pseudonym,\n+ trust_weight,\n } => {\n- let vote = VoteData::from_recorded(*ts, a, b, *ratio_left, *ratio_right)\n- .ok_or_else(|| EventLogError::Apply(format!(\"invalid vote event: {a} vs {b}\")))?;\n- let parent = parent_from_event_scope(scope);\n- vote_writes(\n- &mut batch,\n- &parent,\n- vote.a.as_str(),\n- vote.b.as_str(),\n- *ratio_left,\n- *ratio_right,\n+ let left = (*ratio_left).max(0);\n+ let right = (*ratio_right).max(0);\n+ if left == 0 && right == 0 {\n+ return Err(EventLogError::Apply(format!(\n+ \"invalid vote event: zero weights ({a} vs {b})\"\n+ )));\n+ }\n+ let vote = VoteData::from_event(\n *ts,\n+ a,\n+ b,\n+ left,\n+ right,\n+ pseudonym.clone(),\n+ *trust_weight,\n )\n- .map_err(|e| EventLogError::Apply(e.to_string()))?;\n+ .ok_or_else(|| EventLogError::Apply(format!(\"invalid vote event: {a} vs {b}\")))?;\n+ let actor_uuid = resolve_actor_uuid(db, pseudonym)\n+ .map_err(|e| EventLogError::Apply(e))?;\n+ let parent = parent_from_event_scope(scope);\n+ vote_writes(&mut batch, &parent, &vote, &actor_uuid)\n+ .map_err(|e| EventLogError::Apply(e.to_string()))?;\n }\n Event::NodeEnsured { id } => {\n let parsed = parse_event_id(id)?;\ndiff --git a/server/src/projection_store.rs b/server/src/projection_store.rs\nindex 9a8953d010029d3639dc3987687554bab8b7663e..27fc0bb0519d9f035dceec1fc8b0fa74b00b6b40 100644\n--- a/server/src/projection_store.rs\n+++ b/server/src/projection_store.rs\n@@ -18,7 +18,7 @@ use crate::{\n \n const PROJECTION_CURSOR_KEY: &str = \"cursor\";\n const PROJECTION_SCHEMA_KEY: &str = \"schema_version\";\n-const PROJECTION_SCHEMA_VERSION: u64 = 4;\n+const PROJECTION_SCHEMA_VERSION: u64 = 5;\n \n #[derive(Debug, thiserror::Error)]\n pub enum ProjectionStoreError {\n@@ -51,6 +51,8 @@ impl ProjectionStore {\n if version != Some(PROJECTION_SCHEMA_VERSION) {\n store.reset()?;\n }\n+ crate::identity::seed_default_pseudonym(db)\n+ .map_err(ProjectionStoreError::Durable)?;\n Ok(store)\n }\n \n@@ -206,14 +208,7 @@ mod tests {\n let db = Db::open(tmp.path()).unwrap();\n let store = ProjectionStore::from_db(&db).unwrap();\n \n- let event = Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- };\n+ let event = Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\");\n projection_apply::apply_records(&store, &[record(1, event)]).unwrap();\n assert_eq!(store.last_applied_event_count().unwrap(), 1);\n \n@@ -229,14 +224,7 @@ mod tests {\n let db = Db::open(tmp.path()).unwrap();\n let store = ProjectionStore::from_db(&db).unwrap();\n \n- let event = Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- };\n+ let event = Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\");\n projection_apply::apply_records(&store, &[record(1, event)]).unwrap();\n \n let scoped = store.scope_tree(&ItemId::root()).unwrap();\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex a83f7be120d9420db042479222ca9efc230b6da9..1fc3298d2b2864e9711cd262a034677e45c7090c 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -271,6 +271,7 @@ pub fn group_summary_scores(group: &GroupState) -> HashMap {\n #[cfg(test)]\n mod tests {\n use super::*;\n+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};\n use crate::reducer::VoteData;\n \n fn mk_group() -> GroupState {\n@@ -278,18 +279,11 @@ mod tests {\n }\n \n fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n- use crate::path_types::ItemId;\n- VoteData {\n- ts,\n- a: ItemId::parse(a).unwrap(),\n- b: ItemId::parse(b).unwrap(),\n- ratio_left: l,\n- ratio_right: r,\n- body: \"because\".to_string(),\n- principal: \"test\".to_string(),\n- delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n- thread_tag: \"untagged\".to_string(),\n- }\n+ VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap()\n+ }\n+\n+ fn apply(g: &mut GroupState, v: VoteData) {\n+ g.apply_vote(v, TEST_ACTOR_UUID);\n }\n \n /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n@@ -301,8 +295,8 @@ mod tests {\n #[test]\n fn star_topology_winner_at_top_via_subset() {\n let mut g = mk_group();\n- g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n- g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1), TEST_ACTOR_UUID);\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1), TEST_ACTOR_UUID);\n \n let mut items: Vec<(usize, String)> = g\n .idx_to_item\n@@ -329,7 +323,7 @@ mod tests {\n let mut g = mk_group();\n // Chain a > b > c > d > e > f so ranks are well separated.\n for (hi, lo) in [(\"a\", \"b\"), (\"b\", \"c\"), (\"c\", \"d\"), (\"d\", \"e\"), (\"e\", \"f\")] {\n- g.apply_vote(vote(1, hi, lo, 2, 1));\n+ apply(&mut g, vote(1, hi, lo, 2, 1));\n }\n let (top, bottom) = top_bottom(&g, 2);\n assert_eq!(top.len(), 2);\n@@ -345,7 +339,7 @@ mod tests {\n #[test]\n fn top_bottom_small_group_has_empty_bottom() {\n let mut g = mk_group();\n- g.apply_vote(vote(1, \"a\", \"b\", 2, 1));\n+ apply(&mut g, vote(1, \"a\", \"b\", 2, 1));\n let (top, bottom) = top_bottom(&g, 5);\n assert_eq!(top.len(), 2);\n assert!(bottom.is_empty());\n@@ -355,8 +349,8 @@ mod tests {\n fn connected_components_split_disconnected_pairs() {\n let mut g = mk_group();\n // Two disconnected edges: (a,b) and (c,d)\n- g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n- g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n+ apply(&mut g, vote(1, \"a\", \"b\", 3, 1));\n+ apply(&mut g, vote(2, \"c\", \"d\", 3, 1));\n \n let n = g.idx_to_item.len();\n let (mut comps, isolates) =\n@@ -401,13 +395,16 @@ mod tests {\n let i = *perm[..k].choose(&mut rng).unwrap();\n let j = perm[k];\n let (a, b) = (letters[i], letters[j]);\n- g.apply_vote(vote(\n- k as i64,\n- &a.to_string(),\n- &b.to_string(),\n- (i + 1) as i32,\n- (j + 1) as i32,\n- ));\n+ apply(\n+ &mut g,\n+ vote(\n+ k as i64,\n+ &a.to_string(),\n+ &b.to_string(),\n+ (i + 1) as i32,\n+ (j + 1) as i32,\n+ ),\n+ );\n }\n \n let ranked = ranked_items(&g);\n@@ -426,8 +423,8 @@ mod tests {\n #[test]\n fn subset_ranking_ranks_within_component_only() {\n let mut g = mk_group();\n- g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n- g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n+ apply(&mut g, vote(1, \"a\", \"b\", 3, 1)); // a > b\n+ apply(&mut g, vote(2, \"c\", \"d\", 1, 4)); // d > c\n \n let (comps, _) = connected_components_from_voted_pairs(\n g.idx_to_item.len(),\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 759918b8c0eb8f8bf1ed0911d8877adaa55c8ea6..8c4c9f83635cbcbb037d680dbae2aa99cb2dc785 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -5,27 +5,27 @@ use serde::{Deserialize, Serialize};\n use crate::path_types::ItemId;\n \n /// Parsed pairwise vote (internal representation).\n-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n pub struct VoteData {\n pub ts: i64,\n pub a: ItemId,\n pub b: ItemId,\n pub ratio_left: i32,\n pub ratio_right: i32,\n- pub body: String,\n- pub principal: String,\n- pub delegate: Option,\n- pub thread_tag: String,\n+ pub pseudonym: String,\n+ pub trust_weight: f64,\n }\n \n impl VoteData {\n- /// Build a vote from persisted event fields (web UI / replay).\n- pub fn from_recorded(\n+ /// Build a vote from validated event fields (replay / tests).\n+ pub fn from_event(\n ts: i64,\n a: &str,\n b: &str,\n ratio_left: i32,\n ratio_right: i32,\n+ pseudonym: String,\n+ trust_weight: f64,\n ) -> Option {\n let a = ItemId::from_storage(a)?;\n let b = ItemId::from_storage(b)?;\n@@ -38,10 +38,8 @@ impl VoteData {\n b,\n ratio_left,\n ratio_right,\n- body: String::new(),\n- principal: \"web\".to_string(),\n- delegate: None,\n- thread_tag: \"default\".to_string(),\n+ pseudonym,\n+ trust_weight,\n })\n }\n }\n@@ -52,6 +50,8 @@ pub struct GroupState {\n pub idx_to_item: Vec,\n pub edges: HashMap<(usize, usize), f64>,\n pub voted_pairs: HashSet<(usize, usize)>,\n+ /// Latest vote per `(actor_uuid, min_idx, max_idx)` — Sybil dedup anchor.\n+ pub uuid_votes: HashMap<(String, usize, usize), VoteData>,\n pub recent_votes: Vec,\n }\n \n@@ -62,6 +62,7 @@ impl GroupState {\n idx_to_item: Vec::new(),\n edges: HashMap::new(),\n voted_pairs: HashSet::new(),\n+ uuid_votes: HashMap::new(),\n recent_votes: Vec::new(),\n }\n }\n@@ -83,36 +84,78 @@ impl GroupState {\n *self.edges.entry((src, dst)).or_insert(0.0) += w;\n }\n \n- pub fn apply_vote(&mut self, mut vote: VoteData) {\n- vote.a = ItemId::from_storage(vote.a.as_str()).unwrap_or(vote.a.clone());\n- vote.b = ItemId::from_storage(vote.b.as_str()).unwrap_or(vote.b.clone());\n- if vote.ratio_left < 0 {\n- vote.ratio_left = 0;\n- }\n- if vote.ratio_right < 0 {\n- vote.ratio_right = 0;\n- }\n- if vote.ratio_left == 0 && vote.ratio_right == 0 {\n+ fn subtract_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n+ if w <= 0.0 {\n return;\n }\n- let a_idx = self.ensure_item(&vote.a);\n- let b_idx = self.ensure_item(&vote.b);\n+ if let Some(entry) = self.edges.get_mut(&(src, dst)) {\n+ *entry -= w;\n+ if *entry <= 0.0 {\n+ self.edges.remove(&(src, dst));\n+ }\n+ }\n+ }\n \n+ fn apply_weights(&mut self, vote: &VoteData, a_idx: usize, b_idx: usize) {\n+ let w_a = vote.ratio_left as f64 * vote.trust_weight;\n+ let w_b = vote.ratio_right as f64 * vote.trust_weight;\n let (i, j) = if a_idx < b_idx {\n (a_idx, b_idx)\n } else {\n (b_idx, a_idx)\n };\n-\n- let w_a = vote.ratio_left as f64;\n- let w_b = vote.ratio_right as f64;\n-\n self.voted_pairs.insert((i, j));\n self.add_edge_weight(b_idx, a_idx, w_a);\n self.add_edge_weight(a_idx, b_idx, w_b);\n+ }\n \n+ fn rollback_weights(&mut self, vote: &VoteData) {\n+ let a_idx = match self.item_to_idx.get(&vote.a) {\n+ Some(&i) => i,\n+ None => return,\n+ };\n+ let b_idx = match self.item_to_idx.get(&vote.b) {\n+ Some(&i) => i,\n+ None => return,\n+ };\n+ let w_a = vote.ratio_left as f64 * vote.trust_weight;\n+ let w_b = vote.ratio_right as f64 * vote.trust_weight;\n+ self.subtract_edge_weight(b_idx, a_idx, w_a);\n+ self.subtract_edge_weight(a_idx, b_idx, w_b);\n+ }\n+\n+ /// Apply a validated vote, deduplicating by `actor_uuid` per unordered pair.\n+ pub fn apply_vote(&mut self, vote: VoteData, actor_uuid: &str) {\n+ let a_idx = self.ensure_item(&vote.a);\n+ let b_idx = self.ensure_item(&vote.b);\n+ let (i, j) = if a_idx < b_idx {\n+ (a_idx, b_idx)\n+ } else {\n+ (b_idx, a_idx)\n+ };\n+\n+ let dedupe_key = (actor_uuid.to_string(), i, j);\n+ if let Some(old) = self.uuid_votes.get(&dedupe_key).cloned() {\n+ self.rollback_weights(&old);\n+ }\n+\n+ self.apply_weights(&vote, a_idx, b_idx);\n+ self.uuid_votes.insert(dedupe_key, vote.clone());\n self.recent_votes.push(vote);\n }\n+\n+ /// Rebuild edge weights from deduped uuid votes (load path — no rollback).\n+ pub fn ingest_uuid_vote(&mut self, vote: VoteData, actor_uuid: &str) {\n+ let a_idx = self.ensure_item(&vote.a);\n+ let b_idx = self.ensure_item(&vote.b);\n+ let (i, j) = if a_idx < b_idx {\n+ (a_idx, b_idx)\n+ } else {\n+ (b_idx, a_idx)\n+ };\n+ self.apply_weights(&vote, a_idx, b_idx);\n+ self.uuid_votes.insert((actor_uuid.to_string(), i, j), vote);\n+ }\n }\n \n /// Structured data imported from Reddit or elsewhere.\n@@ -192,14 +235,14 @@ impl GlobalTree {\n self.nodes.get(id)\n }\n \n- pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData) {\n+ pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData, actor_uuid: &str) {\n self.ensure_path(parent);\n self.ensure_path(&vote.a);\n self.ensure_path(&vote.b);\n if let Some(node) = self.nodes.get_mut(parent) {\n node.children.insert(vote.a.clone());\n node.children.insert(vote.b.clone());\n- node.local_ranking.apply_vote(vote);\n+ node.local_ranking.apply_vote(vote, actor_uuid);\n }\n }\n \n@@ -231,36 +274,62 @@ impl GlobalTree {\n }\n \n #[cfg(test)]\n-mod from_recorded_tests {\n+mod tests {\n use super::*;\n \n+ fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32, pseudonym: &str) -> VoteData {\n+ VoteData {\n+ ts,\n+ a: ItemId::opaque(a),\n+ b: ItemId::opaque(b),\n+ ratio_left: l,\n+ ratio_right: r,\n+ pseudonym: pseudonym.to_string(),\n+ trust_weight: 1.0,\n+ }\n+ }\n+\n #[test]\n- fn rejects_same_item() {\n- assert!(VoteData::from_recorded(1, \"a\", \"a\", 2, 1).is_none());\n+ fn from_event_rejects_same_item() {\n+ assert!(VoteData::from_event(\n+ 1,\n+ \"a\",\n+ \"a\",\n+ 2,\n+ 1,\n+ \"anon\".into(),\n+ 1.0\n+ )\n+ .is_none());\n }\n \n #[test]\n- fn rejects_empty_pair() {\n- assert!(VoteData::from_recorded(1, \"\", \"b\", 2, 1).is_none());\n+ fn from_event_rejects_empty_pair() {\n+ assert!(VoteData::from_event(1, \"\", \"b\", 2, 1, \"anon\".into(), 1.0).is_none());\n+ }\n+\n+ #[test]\n+ fn same_uuid_replaces_prior_vote_on_pair() {\n+ let mut g = GroupState::new();\n+ let uuid = \"u1\";\n+ g.apply_vote(vote(1, \"a\", \"b\", 2, 1, \"alice\"), uuid);\n+ let first_total: f64 = g.edges.values().sum();\n+ assert_eq!(first_total, 3.0);\n+\n+ g.apply_vote(vote(2, \"a\", \"b\", 0, 1, \"bob\"), uuid);\n+ let second_total: f64 = g.edges.values().sum();\n+ assert_eq!(second_total, 1.0);\n+ assert_eq!(g.uuid_votes.len(), 1);\n }\n \n #[test]\n- fn zero_weight_vote_does_not_mark_pair_or_edges() {\n+ fn different_uuids_both_count() {\n let mut g = GroupState::new();\n- g.apply_vote(VoteData {\n- ts: 1,\n- a: ItemId::opaque(\"a\"),\n- b: ItemId::opaque(\"b\"),\n- ratio_left: 0,\n- ratio_right: 0,\n- body: String::new(),\n- principal: \"test\".to_string(),\n- delegate: None,\n- thread_tag: \"untagged\".to_string(),\n- });\n- assert!(g.voted_pairs.is_empty());\n- assert!(g.edges.is_empty());\n- assert!(g.recent_votes.is_empty());\n+ g.apply_vote(vote(1, \"a\", \"b\", 2, 1, \"alice\"), \"u1\");\n+ g.apply_vote(vote(2, \"a\", \"b\", 0, 1, \"bob\"), \"u2\");\n+ let total: f64 = g.edges.values().sum();\n+ assert_eq!(total, 4.0);\n+ assert_eq!(g.uuid_votes.len(), 2);\n }\n \n #[test]\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 247b9047a57956f76c4b6bef691662101e62a8f9..8dabc93e95c39cbe18d69596dee79683b384ef86 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -9,7 +9,7 @@ use crate::{\n projection_apply,\n projection_store::ProjectionStore,\n reddit::{RedditApiConfig, RedditBroker, REDDIT_CONTENT_TTL},\n- reducer::{GlobalTree, VoteData},\n+ reducer::GlobalTree,\n view_log::ViewLog,\n views::ViewStore,\n };\n@@ -234,23 +234,31 @@ impl AppState {\n if a_raw.is_empty() || b_raw.is_empty() || a_raw == b_raw {\n return Err(\"invalid vote: need two distinct non-empty items\".to_string());\n }\n- if ratio_left.max(0) == 0 && ratio_right.max(0) == 0 {\n+ let left = ratio_left.max(0);\n+ let right = ratio_right.max(0);\n+ if left == 0 && right == 0 {\n return Err(\n \"invalid vote: need a positive preference on at least one side\".to_string(),\n );\n }\n- // Validate items canonicalize (or are opaque keys) before append.\n- let _ = VoteData::from_recorded(ts, a_raw, b_raw, ratio_left, ratio_right)\n- .ok_or_else(|| \"invalid vote: need two distinct parseable items\".to_string())?;\n+ let a_id = ItemId::from_storage(a_raw)\n+ .or_else(|| ItemId::parse(a_raw))\n+ .ok_or_else(|| \"invalid vote: unparseable item a\".to_string())?;\n+ let b_id = ItemId::from_storage(b_raw)\n+ .or_else(|| ItemId::parse(b_raw))\n+ .ok_or_else(|| \"invalid vote: unparseable item b\".to_string())?;\n+ if a_id == b_id {\n+ return Err(\"invalid vote: need two distinct items\".to_string());\n+ }\n \n- let event = Event::VoteRecorded {\n+ let event = Event::vote_recorded(\n ts,\n- a: a_raw.to_string(),\n- b: b_raw.to_string(),\n- ratio_left,\n- ratio_right,\n- scope: parent.as_str().to_string(),\n- };\n+ a_id.as_str(),\n+ b_id.as_str(),\n+ left,\n+ right,\n+ parent.as_str(),\n+ );\n \n self.journal.append(event).await\n }\n@@ -333,17 +341,7 @@ mod tests {\n id: \"https://reddit.com/r/rust\".into(),\n },\n ),\n- event_record(\n- 2,\n- Event::VoteRecorded {\n- ts: 2,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n- ),\n+ event_record(2, Event::vote_recorded(2, \"alpha\", \"beta\", 2, 1, \"\")),\n ])\n .await\n .unwrap();\n@@ -430,14 +428,7 @@ mod tests {\n let log = EventLog::new(log_path.to_string_lossy().into_owned());\n log.append(&event_record(\n 1,\n- Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n+ Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\"),\n ))\n .await\n .unwrap();\n@@ -601,14 +592,7 @@ mod tests {\n let log = EventLog::new(format!(\"{data_dir}/events.jsonl\"));\n log.append(&event_record(\n 1,\n- Event::VoteRecorded {\n- ts: 1,\n- a: \"alpha\".into(),\n- b: \"beta\".into(),\n- ratio_left: 2,\n- ratio_right: 1,\n- scope: String::new(),\n- },\n+ Event::vote_recorded(1, \"alpha\", \"beta\", 2, 1, \"\"),\n ))\n .await\n .unwrap();\ndiff --git a/server/src/storage_dto.rs b/server/src/storage_dto.rs\nindex 3fd6db5cb909ac4896bd8a3ecace796de5f08781..22f5ac498ae3a5db4347f3fe94830539c7c01e3e 100644\n--- a/server/src/storage_dto.rs\n+++ b/server/src/storage_dto.rs\n@@ -1,9 +1,8 @@\n //! Versioned leaf value DTOs persisted in durable collections.\n //!\n-//! Node structure (children, edges, voted pairs, recent votes) is no longer a\n-//! single blob — it lives as point-addressable durable collections (see\n-//! [`crate::storage_schema`]). This module only defines the small leaf values:\n-//! ephemeral entity views and individual votes.\n+//! Node structure (children, uuid votes, recent votes) lives as point-addressable\n+//! durable collections (see [`crate::storage_schema`]). This module defines the\n+//! small leaf values: ephemeral entity views and individual votes.\n \n use serde::{Deserialize, Serialize};\n \n@@ -12,7 +11,7 @@ use crate::{\n reducer::{EntityData, VoteData},\n };\n \n-pub const VOTE_RECORD_VERSION: u32 = 1;\n+pub const VOTE_RECORD_VERSION: u32 = 2;\n pub const ENTITY_DATA_VERSION: u32 = 1;\n \n #[derive(Debug, Clone, Serialize, Deserialize)]\n@@ -39,7 +38,7 @@ pub struct StoredEntityDataV1 {\n pub link_url: Option,\n }\n \n-/// One vote stored in a node's `recent_votes` list.\n+/// One vote stored in a node's `recent_votes` list or `uuid_votes` map.\n #[derive(Debug, Clone, Serialize, Deserialize)]\n pub struct StoredVoteV1 {\n pub version: u32,\n@@ -48,10 +47,8 @@ pub struct StoredVoteV1 {\n pub b: String,\n pub ratio_left: i32,\n pub ratio_right: i32,\n- pub body: String,\n- pub principal: String,\n- pub delegate: Option,\n- pub thread_tag: String,\n+ pub pseudonym: String,\n+ pub trust_weight: f64,\n }\n \n pub fn encode_entity_data(data: &EntityData) -> StoredEntityDataV1 {\n@@ -85,10 +82,8 @@ pub fn encode_vote(vote: &VoteData) -> StoredVoteV1 {\n b: vote.b.as_str().to_string(),\n ratio_left: vote.ratio_left,\n ratio_right: vote.ratio_right,\n- body: vote.body.clone(),\n- principal: vote.principal.clone(),\n- delegate: vote.delegate.clone(),\n- thread_tag: vote.thread_tag.clone(),\n+ pseudonym: vote.pseudonym.clone(),\n+ trust_weight: vote.trust_weight,\n }\n }\n \n@@ -99,10 +94,8 @@ pub fn decode_vote(vote: StoredVoteV1) -> Result {\n b: parse_stored_id(&vote.b)?,\n ratio_left: vote.ratio_left,\n ratio_right: vote.ratio_right,\n- body: vote.body,\n- principal: vote.principal,\n- delegate: vote.delegate,\n- thread_tag: vote.thread_tag,\n+ pseudonym: vote.pseudonym,\n+ trust_weight: vote.trust_weight,\n })\n }\n \ndiff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs\nindex 5d2bb1d56927fb61c7c6d2d8602bd6882327f862..67c9f4ab2e1865f8da81b6735dd2a05b87e5e366 100644\n--- a/server/src/storage_schema.rs\n+++ b/server/src/storage_schema.rs\n@@ -1,14 +1,13 @@\n //! Durable schema for sorter2 — the projection laid out as point-addressable\n //! durable collections instead of one blob per node.\n //!\n-//! A vote updates a handful of keys: a few edge-weight merges, a voted-pair flag,\n-//! a recent-vote list append, and child-link set entries. The in-memory\n-//! [`crate::reducer::GroupState`] is reconstructed from these keys on read for\n-//! rank-centrality.\n+//! Votes are stored as deduped `uuid_votes` entries plus an append-only\n+//! `recent_votes` audit list. Edge weights for rank centrality are derived\n+//! from `uuid_votes` on read, not incrementally merged in RocksDB.\n \n-use std::collections::{BTreeSet, HashMap, HashSet};\n+use std::collections::{HashSet};\n \n-use durable::{Batch, Db, Durable, Leaf, List, Map, Sum};\n+use durable::{Batch, Db, Durable, Leaf, List, Map};\n \n use crate::{\n path_types::ItemId,\n@@ -19,10 +18,8 @@ use crate::{\n },\n };\n \n-/// Directed edge key `(from_id, to_id)`.\n-pub type EdgeKey = (String, String);\n-/// Unordered voted-pair key, stored canonically as `(min, max)` by string.\n-pub type PairKey = (String, String);\n+/// `(actor_uuid, min_item_id, max_item_id)` — one vote slot per human per pair.\n+pub type UuidVoteKey = (String, String, String);\n \n /// One node in the fractal tree, exploded into precisely-updatable collections.\n #[derive(Durable)]\n@@ -34,22 +31,21 @@ pub struct NodeSchema {\n pub data: Leaf,\n /// Child ids (a set; value is always `true`).\n pub children: Map>,\n- /// Directed edge weights `(from, to) -> weight`, updated by blind merges.\n- pub edges: Map>,\n- /// Voted pairs `(min, max) -> true`.\n- pub voted_pairs: Map>,\n+ /// Latest vote per actor per unordered pair; edges are derived from this on read.\n+ pub uuid_votes: Map>,\n /// Recent votes, append-only oldest-first (cap applied on read).\n pub recent_votes: List>,\n /// When ephemeral Reddit display content was last fetched (ms); absent after eviction.\n pub fetched_at: Leaf,\n }\n \n-/// The single database root: nodes, view counts, and per-concern metadata maps\n-/// (cursors and schema versions).\n+/// The single database root: nodes, identity maps, view counts, and metadata.\n #[derive(Durable)]\n #[allow(dead_code)]\n pub struct Store {\n pub nodes: Map,\n+ /// Global pseudonym → actor UUID (Sybil dedup anchor).\n+ pub pseudonyms: Map>,\n pub proj_meta: Map>,\n pub view_counts: Map>,\n pub view_meta: Map>,\n@@ -62,6 +58,21 @@ fn id_key(id: &ItemId) -> String {\n id.as_str().to_string()\n }\n \n+fn pair_keys(a: &ItemId, b: &ItemId) -> (String, String) {\n+ let ak = id_key(a);\n+ let bk = id_key(b);\n+ if ak <= bk {\n+ (ak, bk)\n+ } else {\n+ (bk, ak)\n+ }\n+}\n+\n+pub fn uuid_vote_key(actor_uuid: &str, a: &ItemId, b: &ItemId) -> UuidVoteKey {\n+ let (lo, hi) = pair_keys(a, b);\n+ (actor_uuid.to_string(), lo, hi)\n+}\n+\n /// Path to a node by id.\n pub fn node(id: &ItemId) -> durable::Path {\n Store::root().nodes().key(&id_key(id))\n@@ -77,16 +88,10 @@ pub fn load_node_state(db: &Db, id: &ItemId) -> durable::Result durable::Result durable::Result,\n- voted: Vec,\n- edges_raw: Vec<(EdgeKey, f64)>,\n-) -> durable::Result {\n- // Item universe = every endpoint that appears in a voted pair or an edge.\n- let mut item_strs: BTreeSet = BTreeSet::new();\n- for (a, b) in &voted {\n- item_strs.insert(a.clone());\n- item_strs.insert(b.clone());\n- }\n- for ((a, b), _) in &edges_raw {\n- item_strs.insert(a.clone());\n- item_strs.insert(b.clone());\n- }\n-\n- let mut idx_to_item: Vec = Vec::with_capacity(item_strs.len());\n- let mut item_to_idx: HashMap = HashMap::with_capacity(item_strs.len());\n- let mut str_to_idx: HashMap = HashMap::with_capacity(item_strs.len());\n- for s in item_strs {\n- let id = parse_storage_id(&s)?;\n- let idx = idx_to_item.len();\n- str_to_idx.insert(s, idx);\n- item_to_idx.insert(id.clone(), idx);\n- idx_to_item.push(id);\n- }\n+fn build_group_state(db: &Db, np: &durable::Path) -> durable::Result {\n+ let mut group = GroupState::new();\n \n- let mut edges: HashMap<(usize, usize), f64> = HashMap::with_capacity(edges_raw.len());\n- for ((a, b), w) in edges_raw {\n- if let (Some(&ai), Some(&bi)) = (str_to_idx.get(&a), str_to_idx.get(&b)) {\n- edges.insert((ai, bi), w);\n- }\n- }\n-\n- let mut voted_pairs: HashSet<(usize, usize)> = HashSet::with_capacity(voted.len());\n- for (a, b) in voted {\n- if let (Some(&ai), Some(&bi)) = (str_to_idx.get(&a), str_to_idx.get(&b)) {\n- let (i, j) = if ai < bi { (ai, bi) } else { (bi, ai) };\n- voted_pairs.insert((i, j));\n- }\n+ for (key, stored) in np.uuid_votes().iter(db)? {\n+ let (actor_uuid, _lo, _hi) = key;\n+ let vote = decode_vote(stored).map_err(durable::Error::Deserialize)?;\n+ group.ingest_uuid_vote(vote, &actor_uuid);\n }\n \n- // List is index order (oldest first); keep the newest RECENT_VOTES_CAP entries.\n let stored = np.recent_votes().iter(db)?;\n let cap = RECENT_VOTES_CAP as usize;\n let start = stored.len().saturating_sub(cap);\n- let recent_votes = stored[start..]\n+ group.recent_votes = stored[start..]\n .iter()\n .map(|s| decode_vote(s.clone()).map_err(durable::Error::Deserialize))\n .collect::, _>>()?;\n \n- Ok(GroupState {\n- item_to_idx,\n- idx_to_item,\n- edges,\n- voted_pairs,\n- recent_votes,\n- })\n+ Ok(group)\n }\n \n fn parse_storage_id(s: &str) -> durable::Result {\n@@ -197,73 +161,24 @@ pub fn ensure_path_writes(batch: &mut Batch, id: &ItemId) {\n }\n }\n \n-/// Reified writes for a recorded vote under `parent`. Mirrors\n-/// [`crate::reducer::GroupState::apply_vote`] as point updates.\n+/// Reified writes for a validated vote under `parent`.\n pub fn vote_writes(\n batch: &mut Batch,\n parent: &ItemId,\n- a: &str,\n- b: &str,\n- ratio_left: i32,\n- ratio_right: i32,\n- ts: i64,\n+ vote: &VoteData,\n+ actor_uuid: &str,\n ) -> durable::Result<()> {\n- let left = ratio_left.max(0);\n- let right = ratio_right.max(0);\n- if left == 0 && right == 0 {\n- return Ok(());\n- }\n-\n- let a_id = ItemId::from_storage(a).unwrap_or_else(|| ItemId::opaque(a));\n- let b_id = ItemId::from_storage(b).unwrap_or_else(|| ItemId::opaque(b));\n-\n ensure_path_writes(batch, parent);\n- ensure_path_writes(batch, &a_id);\n- ensure_path_writes(batch, &b_id);\n+ ensure_path_writes(batch, &vote.a);\n+ ensure_path_writes(batch, &vote.b);\n \n let pnode = node(parent);\n- batch.write(pnode.children().key(&id_key(&a_id)).set(&true));\n- batch.write(pnode.children().key(&id_key(&b_id)).set(&true));\n-\n- // Edge weights: edge (b,a) += left, edge (a,b) += right (positive only).\n- if left > 0 {\n- batch.write(\n- pnode\n- .edges()\n- .key(&(id_key(&b_id), id_key(&a_id)))\n- .add(left as f64),\n- );\n- }\n- if right > 0 {\n- batch.write(\n- pnode\n- .edges()\n- .key(&(id_key(&a_id), id_key(&b_id)))\n- .add(right as f64),\n- );\n- }\n+ batch.write(pnode.children().key(&id_key(&vote.a)).set(&true));\n+ batch.write(pnode.children().key(&id_key(&vote.b)).set(&true));\n \n- // Voted pair, canonicalized.\n- let (lo, hi) = if id_key(&a_id) <= id_key(&b_id) {\n- (id_key(&a_id), id_key(&b_id))\n- } else {\n- (id_key(&b_id), id_key(&a_id))\n- };\n- batch.write(pnode.voted_pairs().key(&(lo, hi)).set(&true));\n-\n- // Recent votes (append-only; cap on read).\n- let stored = encode_vote(&VoteData {\n- ts,\n- a: a_id,\n- b: b_id,\n- ratio_left: left,\n- ratio_right: right,\n- body: String::new(),\n- principal: \"web\".to_string(),\n- delegate: None,\n- thread_tag: \"default\".to_string(),\n- });\n- batch.push(&pnode.recent_votes(), &stored)?;\n+ let key = uuid_vote_key(actor_uuid, &vote.a, &vote.b);\n+ batch.write(pnode.uuid_votes().key(&key).set(&encode_vote(vote)));\n+ batch.push(&pnode.recent_votes(), &encode_vote(vote))?;\n Ok(())\n }\n \n@@ -283,15 +198,30 @@ pub fn entity_content_clear_writes(batch: &mut Batch, id: &ItemId) {\n #[cfg(test)]\n mod tests {\n use super::*;\n+ use crate::identity::{seed_default_pseudonym, DEFAULT_ACTOR_UUID, DEFAULT_PSEUDONYM};\n+\n+ fn sample_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+ VoteData {\n+ ts,\n+ a: ItemId::opaque(a),\n+ b: ItemId::opaque(b),\n+ ratio_left: l,\n+ ratio_right: r,\n+ pseudonym: DEFAULT_PSEUDONYM.to_string(),\n+ trust_weight: 1.0,\n+ }\n+ }\n \n #[test]\n fn vote_roundtrip_reconstructs_group_state() {\n let dir = tempfile::tempdir().unwrap();\n let db = Db::open(dir.path()).unwrap();\n+ seed_default_pseudonym(&db).unwrap();\n let parent = ItemId::root();\n \n+ let vote = sample_vote(1, \"alpha\", \"beta\", 2, 1);\n let mut batch = db.batch();\n- vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 2, 1, 1).unwrap();\n+ vote_writes(&mut batch, &parent, &vote, DEFAULT_ACTOR_UUID).unwrap();\n batch.commit().unwrap();\n \n let node_state = load_node_state(&db, &parent).unwrap().unwrap();\n@@ -305,16 +235,38 @@ mod tests {\n }\n \n #[test]\n- fn zero_weight_vote_writes_nothing() {\n+ fn uuid_vote_replace_updates_edges() {\n let dir = tempfile::tempdir().unwrap();\n let db = Db::open(dir.path()).unwrap();\n let parent = ItemId::root();\n+ let uuid = \"u1\";\n \n let mut batch = db.batch();\n- vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 0, 0, 1).unwrap();\n+ vote_writes(\n+ &mut batch,\n+ &parent,\n+ &sample_vote(1, \"alpha\", \"beta\", 2, 1),\n+ uuid,\n+ )\n+ .unwrap();\n+ vote_writes(\n+ &mut batch,\n+ &parent,\n+ &VoteData {\n+ pseudonym: \"alias2\".into(),\n+ ratio_left: 0,\n+ ratio_right: 1,\n+ ..sample_vote(2, \"alpha\", \"beta\", 0, 1)\n+ },\n+ uuid,\n+ )\n+ .unwrap();\n batch.commit().unwrap();\n \n- assert!(load_node_state(&db, &parent).unwrap().is_none());\n+ let g = &load_node_state(&db, &parent).unwrap().unwrap().local_ranking;\n+ let edge_total: f64 = g.edges.values().sum();\n+ assert_eq!(edge_total, 1.0);\n+ assert_eq!(g.uuid_votes.len(), 1);\n }\n \n #[test]\n@@ -325,7 +277,13 @@ mod tests {\n \n let mut batch = db.batch();\n for i in 0..RECENT_VOTES_CAP + 10 {\n- vote_writes(&mut batch, &parent, \"alpha\", \"beta\", 1, 0, i as i64).unwrap();\n+ vote_writes(\n+ &mut batch,\n+ &parent,\n+ &sample_vote(i as i64, \"alpha\", \"beta\", 1, 0),\n+ \"u1\",\n+ )\n+ .unwrap();\n }\n batch.commit().unwrap();\n \n@@ -337,13 +295,13 @@ mod tests {\n let node_state = load_node_state(&db, &parent).unwrap().unwrap();\n assert_eq!(node_state.local_ranking.recent_votes.len(), RECENT_VOTES_CAP as usize);\n assert_eq!(\n- node_state.local_ranking.recent_votes.first().map(|v| v.ts),\n+ node_state\n+ .local_ranking\n+ .recent_votes\n+ .first()\n+ .map(|v| v.ts),\n Some(10)\n );\n- assert_eq!(\n- node_state.local_ranking.recent_votes.last().map(|v| v.ts),\n- Some(RECENT_VOTES_CAP as i64 + 9)\n- );\n }\n \n #[test]\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}