{"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[78964dd7] nice\n\nSide A — unified diff (full patch):\ndiff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs\nindex 9b6c2d0964640a157bca2a6a64cb0820238d8e4f..3e0309ff1c2ac1b17923922d2340ba6710e0f9d1 100644\n--- a/server/src/fetch/html.rs\n+++ b/server/src/fetch/html.rs\n@@ -11,6 +11,9 @@ use crate::{\n };\n \n fn entity_panel(node: &NodeState) -> Markup {\n+ if let Some(markup) = crate::render::reddit::entity_markup(node) {\n+ return markup;\n+ }\n html! {\n @if let Some(data) = &node.data {\n div id=\"entity-panel\" class=\"entity-card\" {\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex e88cc43ddc9d8100f7994be6f5960ec4d8f22c55..3bf9fc7e92e50beed24e2c25106a77421038c90b 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -153,16 +153,21 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {\n }\n }\n \n-fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n+fn rank_list(label: &str, items: &[RankedItem], start_rank: usize, tree: &GlobalTree) -> Markup {\n html! {\n @if !items.is_empty() {\n h3 class=\"rank-heading muted small\" { (label) }\n ol class=\"rank-list\" {\n @for (i, r) in items.iter().enumerate() {\n- li {\n+ @let href = item_href(&r.item);\n+ li class=(if crate::render::reddit::is_reddit_post(&r.item) { \"reddit-post-row\" } else { \"\" }) {\n span class=\"rank-num\" { (start_rank + i) \". \" }\n- a href=(item_href(&r.item)) {\n- strong { (display_label(&r.item)) }\n+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, &r.item, &href) {\n+ (row)\n+ } @else {\n+ a href=(href) {\n+ strong { (display_label(&r.item)) }\n+ }\n }\n span class=\"muted\" {\n \" — \"\n@@ -196,9 +201,14 @@ fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup {\n h3 class=\"rank-heading muted small\" { (label) }\n ul class=\"rank-list unranked\" {\n @for it in items {\n- li {\n- a href=(item_href(it)) {\n- strong { (child_label(tree, it)) }\n+ @let href = item_href(it);\n+ li class=(if crate::render::reddit::is_reddit_post(it) { \"reddit-post-row\" } else { \"\" }) {\n+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, it, &href) {\n+ (row)\n+ } @else {\n+ a href=(href) {\n+ strong { (child_label(tree, it)) }\n+ }\n }\n }\n }\n@@ -253,7 +263,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Mark\n } @else {\n @for (gi, ranked) in ranked_groups.iter().enumerate() {\n @let label = if multi { format!(\"Ranking group {}\", gi + 1) } else { \"Ranking\".to_string() };\n- (rank_list(&label, ranked, 1))\n+ (rank_list(&label, ranked, 1, tree))\n }\n (unranked_list(\"Unranked\", &unranked, tree))\n }\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 0677363e0ed21d244bfa065f7ec728549ddd9e50..da5f3ebecec1794b05a2a69cc78379551b2ad769 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -8,6 +8,7 @@ pub mod parser;\n pub mod path_types;\n pub mod ranking;\n pub mod reddit;\n+pub mod render;\n pub mod reducer;\n pub mod journal;\n pub mod state;\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 69d979bc7e4a1cb078f70114dc539bdc1986b574..1168ec2afc77c092514eec91b513bac301cbe225 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -608,6 +608,8 @@ fn parse_subreddit_about(v: &Value) -> Option {\n author: None,\n body_html,\n thumb_url,\n+ image_url: None,\n+ link_url: None,\n })\n }\n \n@@ -635,15 +637,64 @@ fn parse_post_listing(v: &Value) -> Option {\n .and_then(|t| t.as_str())\n .filter(|s| s.starts_with(\"http\"))\n .map(|s| s.to_string());\n+ let image_url = reddit_post_image_url(child);\n+ let link_url = reddit_post_link_url(child);\n \n Some(crate::reducer::EntityData {\n title,\n author,\n body_html,\n thumb_url,\n+ image_url,\n+ link_url,\n })\n }\n \n+fn reddit_post_link_url(data: &Value) -> Option {\n+ for key in [\"url_overridden_by_dest\", \"url\"] {\n+ if let Some(u) = data.get(key).and_then(|v| v.as_str()) {\n+ if u.starts_with(\"http\") {\n+ return Some(u.to_string());\n+ }\n+ }\n+ }\n+ None\n+}\n+\n+/// Full-size still for post detail: direct image `url`, else Reddit preview source.\n+fn reddit_post_image_url(data: &Value) -> Option {\n+ for key in [\"url\", \"url_overridden_by_dest\"] {\n+ if let Some(u) = data.get(key).and_then(|v| v.as_str()) {\n+ if reddit_direct_image_url(u) {\n+ return Some(u.to_string());\n+ }\n+ }\n+ }\n+ reddit_preview_source_url(data)\n+}\n+\n+fn reddit_preview_source_url(data: &Value) -> Option {\n+ data.pointer(\"/preview/images/0/source/url\")\n+ .and_then(|v| v.as_str())\n+ .filter(|s| s.starts_with(\"http\"))\n+ .map(str::to_string)\n+}\n+\n+fn reddit_direct_image_url(url: &str) -> bool {\n+ let u = url.to_ascii_lowercase();\n+ if u.contains(\"redgifs.com\") {\n+ return false;\n+ }\n+ u.contains(\"i.redd.it\")\n+ || u.contains(\"preview.redd.it\")\n+ || u.contains(\"external-preview.redd.it\")\n+ || u.ends_with(\".jpg\")\n+ || u.ends_with(\".jpeg\")\n+ || u.ends_with(\".png\")\n+ || u.ends_with(\".gif\")\n+ || u.ends_with(\".webp\")\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -672,4 +723,20 @@ mod tests {\n .unwrap();\n assert_eq!(entity.title, \"The Rust Programming Language\");\n }\n+\n+ #[test]\n+ fn parse_post_listing_extracts_thumb_and_full_preview() {\n+ let json = include_str!(\"../../test/fixtures/reddit/post_preview.json\");\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes\").unwrap();\n+ let entity = entity_view_from_payload(&id, &v).unwrap();\n+ assert_eq!(entity.title, \"Angel Eyes\");\n+ assert!(entity.thumb_url.as_ref().unwrap().contains(\"width=140\"));\n+ assert!(entity.image_url.as_ref().unwrap().contains(\"auto=webp\"));\n+ assert!(!entity.image_url.as_ref().unwrap().contains(\"redgifs\"));\n+ assert_eq!(\n+ entity.link_url.as_deref(),\n+ Some(\"http://v3.redgifs.com/watch/impossibleprestigioushedgehog\")\n+ );\n+ }\n }\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 4e42d0369dab50bb2f8ca664aa69b628292f6c07..336e78b77d3ab59361b89af5c9868e13da4ac962 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -122,7 +122,12 @@ pub struct EntityData {\n pub title: String,\n pub author: Option,\n pub body_html: Option,\n+ /// Small preview (subreddit listing / child rows).\n pub thumb_url: Option,\n+ /// Full-size still image for the post detail view.\n+ pub image_url: Option,\n+ /// Outbound link for link/video posts (`url` / `url_overridden_by_dest`).\n+ pub link_url: Option,\n }\n \n /// One node in the fractal tree: entity + ranked children.\ndiff --git a/server/src/render/mod.rs b/server/src/render/mod.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c2c41404abe01c671139658fcccdeced5be388e4\n--- /dev/null\n+++ b/server/src/render/mod.rs\n@@ -0,0 +1,3 @@\n+//! Domain-specific HTML fragments for imported entities.\n+\n+pub mod reddit;\ndiff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..4a18de93cf57d8395ded3caa373a708f39b3f43f\n--- /dev/null\n+++ b/server/src/render/reddit.rs\n@@ -0,0 +1,64 @@\n+//! Reddit post cards: thumbnail in child lists, full image on the post page.\n+\n+use maud::{html, Markup};\n+\n+use crate::{\n+ path_types::ItemId,\n+ reducer::{EntityData, GlobalTree, NodeState},\n+};\n+\n+pub fn is_reddit_post(id: &ItemId) -> bool {\n+ id.as_str().starts_with(\"reddit.com/\") && id.as_str().contains(\"/comments/\")\n+}\n+\n+/// Post detail card (`#entity-panel`).\n+pub fn entity_markup(node: &NodeState) -> Option {\n+ if !is_reddit_post(&node.id) {\n+ return None;\n+ }\n+ let data = node.data.as_ref()?;\n+ Some(post_entity_card(data))\n+}\n+\n+/// One row in a parent ranking list (thumbnail + title).\n+pub fn child_row_markup(tree: &GlobalTree, id: &ItemId, href: &str) -> Option {\n+ if !is_reddit_post(id) {\n+ return None;\n+ }\n+ let data = tree.get(id)?.data.as_ref()?;\n+ Some(html! {\n+ @if let Some(thumb) = &data.thumb_url {\n+ a class=\"reddit-post-thumb-link\" href=(href) {\n+ img class=\"reddit-post-thumb\" src=(thumb) alt=\"\" loading=\"lazy\";\n+ }\n+ }\n+ a href=(href) {\n+ strong { (data.title) }\n+ }\n+ })\n+}\n+\n+fn post_entity_card(data: &EntityData) -> Markup {\n+ let image = data.image_url.as_ref().or(data.thumb_url.as_ref());\n+ html! {\n+ div id=\"entity-panel\" class=\"entity-card reddit-post\" {\n+ h2 { (data.title) }\n+ @if let Some(author) = &data.author {\n+ p class=\"muted small\" { \"by \" (author) }\n+ }\n+ @if let Some(url) = &data.link_url {\n+ p class=\"reddit-post-url muted small\" {\n+ a href=(url) rel=\"noopener noreferrer\" { (url) }\n+ }\n+ }\n+ @if let Some(src) = image {\n+ figure class=\"reddit-post-figure\" {\n+ img class=\"reddit-post-image\" src=(src) alt=\"\" loading=\"lazy\";\n+ }\n+ }\n+ @if let Some(body) = &data.body_html {\n+ div class=\"entity-body\" { (maud::PreEscaped(body)) }\n+ }\n+ }\n+ }\n+}\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 1f9f5158414902d43a380dd899232c70d8cf5d63..021918f8e778b84e4b3eac8559fab7d223a3d490 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -171,6 +171,41 @@ code {\n margin-top: 0;\n }\n \n+.reddit-post-row {\n+ display: flex;\n+ align-items: center;\n+ gap: 0.6rem;\n+}\n+\n+.reddit-post-thumb-link {\n+ flex-shrink: 0;\n+ line-height: 0;\n+}\n+\n+.reddit-post-thumb {\n+ width: 64px;\n+ height: 64px;\n+ object-fit: cover;\n+ border-radius: 4px;\n+ border: 1px solid var(--border);\n+}\n+\n+.reddit-post-url a {\n+ word-break: break-all;\n+}\n+\n+.reddit-post-figure {\n+ margin: 0.75rem 0;\n+}\n+\n+.reddit-post-image {\n+ display: block;\n+ max-width: 100%;\n+ height: auto;\n+ border-radius: 6px;\n+ border: 1px solid var(--border);\n+}\n+\n .scope-name {\n color: var(--accent);\n font-weight: 600;\ndiff --git a/test/fixtures/reddit/post_preview.json b/test/fixtures/reddit/post_preview.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..c6ed31ae397dc0f7b6e07e5e010e617df78d9c18\n--- /dev/null\n+++ b/test/fixtures/reddit/post_preview.json\n@@ -0,0 +1,22 @@\n+{\n+ \"kind\": \"t3\",\n+ \"data\": {\n+ \"title\": \"Angel Eyes\",\n+ \"permalink\": \"/r/nsfw/comments/1tpy6a1/angel_eyes/\",\n+ \"author\": \"alice\",\n+ \"thumbnail\": \"https://external-preview.redd.it/Kb6Nf5Q4RAucat-2RFcYMeQb2d6vgYNo5EPlf6kGLbs.jpeg?width=140&height=140&auto=webp&s=d7238240ebf191b954ef5f42b0cf61b67aecec88\",\n+ \"url\": \"http://v3.redgifs.com/watch/impossibleprestigioushedgehog\",\n+ \"post_hint\": \"rich:video\",\n+ \"preview\": {\n+ \"images\": [\n+ {\n+ \"source\": {\n+ \"url\": \"https://external-preview.redd.it/Kb6Nf5Q4RAucat-2RFcYMeQb2d6vgYNo5EPlf6kGLbs.jpeg?auto=webp&s=ccdabee3b81e46ef2d7cec072f6604a09f61dc9a\",\n+ \"width\": 480,\n+ \"height\": 480\n+ }\n+ }\n+ ]\n+ }\n+ }\n+}\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[674964ef] refactor: Deref for href newtypes, CanonicalItemUrl through resolve_item\n\n- Implement Deref for GardenItemUrl, ForumThreadUrl, TildeOntologyPath\n- resolve_item returns CanonicalItemUrl; validate uses HashSet\n- compute_scope_rank_changes keys are CanonicalItemUrl; pair RPC uses Vec pool\n- pick_random_distinct_canonical; connectivity stats on &[CanonicalItemUrl]\n- Global rank unranked uses stored ids before GardenItemUrl mapping\n\nMade-with: Cursor\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs\nindex 03b3e77911ccd662bec8635345dafe2593cf242e..1b291db83df7364a026f2e147e0a29a70a399371 100644\n--- a/server/src/api/helpers.rs\n+++ b/server/src/api/helpers.rs\n@@ -30,13 +30,13 @@ pub fn now_ms() -> i64 {\n t.as_millis() as i64\n }\n \n-/// Resolve an item path as a first-class canonical path.\n-pub fn resolve_item(item: &str) -> Result {\n+/// Resolve DSL/user input to a stored canonical item id.\n+pub fn resolve_item(item: &str) -> Result {\n let canonical = canonicalize_item(item);\n if canonical.is_empty() {\n return Err(format!(\"empty item path: `{}`\", item));\n }\n- Ok(canonical)\n+ Ok(CanonicalItemUrl(canonical))\n }\n \n pub fn parse_parent_specs(parent: Option<&String>) -> Vec {\n@@ -94,7 +94,7 @@ pub fn paginate_rankings(\n (out_components, out_unranked)\n }\n \n-pub fn pick_random_distinct(items: &[String]) -> Option<(String, String)> {\n+pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(CanonicalItemUrl, CanonicalItemUrl)> {\n use rand::seq::SliceRandom;\n if items.len() < 2 {\n return None;\n@@ -123,15 +123,12 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo\n group.voted_pairs.contains(&(i, j))\n }\n \n-pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {\n+pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[CanonicalItemUrl]) -> ConnectivityStats {\n let n = pool.len();\n \n let global_idxs: Vec> = pool\n .iter()\n- .map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n+ .map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let present: Vec = global_idxs.iter().filter_map(|x| *x).collect();\n \ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex cf22cb0129366c3aed031bc86f3197a4321cb806..a10ce662105cff8fad949c6b83f7035ce79bed18 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -24,7 +24,7 @@ pub use auth::{\n \n pub use helpers::{\n api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,\n- parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path,\n+ parse_parent_specs, pick_random_distinct_canonical, resolve_item, sha256_hex, vote_touches_path,\n };\n \n pub use rpc::handle_rpc_batch;\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 5f7d50188f1381267402f2e57e671234ef5db2fd..de0955d0887d32740e1fd18365205c5bdb53c247 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -29,7 +29,7 @@ use crate::{\n use super::auth::verify_bearer_principal;\n use super::helpers::{\n compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,\n- pick_random_distinct, resolve_item, vote_touches_path,\n+ pick_random_distinct_canonical, resolve_item, vote_touches_path,\n };\n use super::validate::{normalize_room_and_thread, validate_ingest_document};\n \n@@ -146,21 +146,21 @@ fn authorize_room_read(reduced: &ReducerState, headers: &HeaderMap, room: &str)\n }\n \n fn compute_scope_rank_changes(\n- parent: &str,\n+ parent: &CanonicalItemUrl,\n before: &crate::scope_rank::ChildrenRankings,\n after: &crate::scope_rank::ChildrenRankings,\n room_wire: &str,\n ) -> Option {\n- fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n+ fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> {\n let mut map = HashMap::new();\n for comp in &rankings.component_rankings {\n let total = comp.ranked.len();\n for (i, item) in comp.ranked.iter().enumerate() {\n- map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total }));\n+ map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total }));\n }\n }\n for item in &rankings.unranked_items {\n- map.insert(item.as_str().to_string(), None);\n+ map.insert(item.clone(), None);\n }\n map\n }\n@@ -168,7 +168,7 @@ fn compute_scope_rank_changes(\n let before_pos = build_positions(before);\n let after_pos = build_positions(after);\n \n- let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n+ let all_items: std::collections::BTreeSet = before_pos.keys().cloned()\n .chain(after_pos.keys().cloned())\n .collect();\n \n@@ -184,7 +184,7 @@ fn compute_scope_rank_changes(\n };\n if changed {\n changes.push(RankChange {\n- item: GardenItemUrl::from_storage_str(&item, room_wire),\n+ item: GardenItemUrl::from_stored(&item, room_wire),\n before: b,\n after: a,\n });\n@@ -203,11 +203,7 @@ fn compute_scope_rank_changes(\n });\n \n Some(ScopeRankChanges {\n- parent: if parent.is_empty() {\n- \"/\".to_string()\n- } else {\n- GardenItemUrl::from_storage_str(parent, room_wire).into_inner()\n- },\n+ parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),\n changes,\n })\n }\n@@ -473,8 +469,8 @@ async fn rpc_post(\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\n@@ -525,7 +521,7 @@ async fn rpc_post(\n .filter_map(|p| {\n let before = pre_rankings.get(p)?;\n let after = crate::scope_rank::build_children_rankings(content, p);\n- compute_scope_rank_changes(p.as_str(), before, &after, &room_key)\n+ compute_scope_rank_changes(p, before, &after, &room_key)\n })\n .collect();\n if v.is_empty() { None } else { Some(v) }\n@@ -638,8 +634,8 @@ async fn rpc_check(\n for s in &v.doc.statements {\n if let dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\n@@ -961,7 +957,7 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<&\n async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result {\n let scope = scope_from_room_wire(&room);\n let reduced_arc = state.reduced.clone();\n- let pool: Vec = {\n+ let pool: Vec = {\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n let tmp = if parent_path.trim().is_empty() {\n@@ -970,12 +966,11 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(parent_path.clone())\n };\n let specs = parse_parent_specs(tmp.as_ref());\n- let raw_pool: Vec = if specs.is_empty() {\n+ if specs.is_empty() {\n content.ranking_group.idx_to_item.clone()\n } else {\n crate::scope_rank::resolve_scope(content, &specs)\n- };\n- raw_pool.into_iter().map(|it| it.0).collect()\n+ }\n };\n if pool.len() < 2 {\n return Err((\n@@ -983,31 +978,30 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n Some(\"add items via ingest\".into()),\n ));\n }\n- let selected: Option<(String, String)> = {\n+ let selected: Option<(CanonicalItemUrl, CanonicalItemUrl)> = {\n let mut reduced = reduced_arc.write().await;\n let content = reduced.content.entry(scope.clone()).or_default();\n let group = &mut content.ranking_group;\n if group.idx_to_item.is_empty() {\n- pick_random_distinct(&pool)\n+ pick_random_distinct_canonical(&pool)\n } else {\n let mut rng = rand::thread_rng();\n- let idxs: Vec = pool.iter()\n- .filter_map(|it| {\n- let key = CanonicalItemUrl(it.clone());\n- group.item_to_idx.get(&key).copied()\n- })\n+ let idxs: Vec = pool\n+ .iter()\n+ .filter_map(|it| group.item_to_idx.get(it).copied())\n .collect();\n let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8);\n- let ranked_set: HashSet = ranked.iter().map(|r| r.item.as_str().to_string()).collect();\n- let unsorted: Vec = pool.iter()\n+ let ranked_set: HashSet = ranked.iter().map(|r| r.item.clone()).collect();\n+ let unsorted: Vec = pool\n+ .iter()\n .filter(|it| !ranked_set.contains(*it))\n .cloned()\n .collect();\n- let mut pick: Option<(String, String)> = None;\n+ let mut pick: Option<(CanonicalItemUrl, CanonicalItemUrl)> = None;\n if !unsorted.is_empty() {\n if let Some(left) = unsorted.choose(&mut rng).cloned() {\n- let mut candidates: Vec = if !ranked.is_empty() {\n- ranked.iter().map(|r| r.item.as_str().to_string()).collect()\n+ let mut candidates: Vec = if !ranked.is_empty() {\n+ ranked.iter().map(|r| r.item.clone()).collect()\n } else {\n pool.clone()\n };\n@@ -1021,21 +1015,21 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n let a = ranked[i].item.as_str();\n let b = ranked[i + 1].item.as_str();\n if a != b && !is_pair_voted(group, a, b) {\n- pick = Some((a.to_string(), b.to_string()));\n+ pick = Some((ranked[i].item.clone(), ranked[i + 1].item.clone()));\n break;\n }\n }\n if pick.is_none() {\n for _ in 0..64 {\n let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; };\n- if a != b && !is_pair_voted(group, &a, &b) {\n+ if a != b && !is_pair_voted(group, a.as_str(), b.as_str()) {\n pick = Some((a, b));\n break;\n }\n }\n }\n }\n- pick.or_else(|| pick_random_distinct(&pool))\n+ pick.or_else(|| pick_random_distinct_canonical(&pool))\n }\n };\n let Some((left, right)) = selected else {\n@@ -1043,8 +1037,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n };\n let reduced = reduced_arc.read().await;\n let content = content_for_room(&reduced, &room);\n- let left_key = CanonicalItemUrl(left.clone());\n- let right_key = CanonicalItemUrl(right.clone());\n+ let left_key = left.clone();\n+ let right_key = right.clone();\n let lb = content.item_bodies.get(&left_key).cloned();\n let rb = content.item_bodies.get(&right_key).cloned();\n let th: Vec = content\n@@ -1058,8 +1052,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re\n .collect();\n let cs = compute_connectivity_stats(&content.ranking_group, &pool);\n Ok(RpcResult::Pair(PairResponse {\n- left: GardenItemUrl::from_storage_str(&left, &room),\n- right: GardenItemUrl::from_storage_str(&right, &room),\n+ left: GardenItemUrl::from_stored(&left, &room),\n+ right: GardenItemUrl::from_stored(&right, &room),\n left_body: lb,\n right_body: rb,\n threads: th,\n@@ -1554,7 +1548,7 @@ pub async fn handle_rpc_batch(\n for r in items {\n let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0));\n ranked.push(RankRow {\n- item: GardenItemUrl::from_storage_str(r.item.as_str(), &room),\n+ item: GardenItemUrl::from_stored(&r.item, &room),\n score: r.score,\n percent: pct,\n });\n@@ -1562,11 +1556,11 @@ pub async fn handle_rpc_batch(\n }\n \n let ranked_total = ranked.len();\n- let mut unranked: Vec = content\n+ let mut unranked: Vec = content\n .items\n .iter()\n .filter(|it| !group.item_to_idx.contains_key(*it))\n- .map(|it| it.as_str().to_string())\n+ .cloned()\n .collect();\n unranked.sort();\n let unranked_total = unranked.len();\n@@ -1574,7 +1568,7 @@ pub async fn handle_rpc_batch(\n let page: Vec = ranked\n .into_iter()\n .chain(unranked.into_iter().map(|it| RankRow {\n- item: GardenItemUrl::from_storage_str(&it, &room),\n+ item: GardenItemUrl::from_stored(&it, &room),\n score: 0.0,\n percent: want_percent.then_some(0.0),\n }))\n@@ -1722,7 +1716,7 @@ pub async fn handle_rpc_batch(\n .filter(|p| !parents.contains(p.as_str()))\n .map(|p| GardenItemUrl::from_stored(p, &room))\n .collect();\n- paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));\n+ paths.sort();\n line_ok(RpcResult::Leaves(LeavesResponse { paths }))\n }\n },\ndiff --git a/server/src/api/validate.rs b/server/src/api/validate.rs\nindex a51c783ee9785569b5a44c0b1572471fe00d174b..3c7aa80fd485cf247231fe5c5c7e6fea62f22534 100644\n--- a/server/src/api/validate.rs\n+++ b/server/src/api/validate.rs\n@@ -52,7 +52,7 @@ pub fn validate_ingest_document(\n };\n \n let ts = super::helpers::now_ms();\n- let mut defined_in_doc: HashSet = HashSet::new();\n+ let mut defined_in_doc: HashSet = HashSet::new();\n \n for s in &doc.statements {\n match s {\n@@ -66,14 +66,14 @@ pub fn validate_ingest_document(\n let Some(body_text) = body else {\n return Err((\n StatusCode::BAD_REQUEST,\n- format!(\"item missing body: {}\", GardenItemUrl::from_storage_str(&item, room_wire)),\n+ format!(\"item missing body: {}\", GardenItemUrl::from_stored(&item, room_wire)),\n Some(\"items must be declared with bodies, e.g. `~/path/item { ... }`\".to_string()),\n ));\n };\n if body_text.trim().is_empty() {\n return Err((\n StatusCode::BAD_REQUEST,\n- format!(\"item body is empty: {}\", GardenItemUrl::from_storage_str(&item, room_wire)),\n+ format!(\"item body is empty: {}\", GardenItemUrl::from_stored(&item, room_wire)),\n Some(\"write at least one sentence inside `{ ... }`\".to_string()),\n ));\n }\n@@ -102,11 +102,8 @@ pub fn validate_ingest_document(\n };\n let missing: Vec = [&a, &b]\n .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !item_exists(&key)\n- })\n- .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner())\n+ .filter(|it| !defined_in_doc.contains(*it) && !item_exists(it))\n+ .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner())\n .collect();\n if !missing.is_empty() {\n return Err((\n@@ -120,11 +117,8 @@ pub fn validate_ingest_document(\n }\n let missing_body: Vec = [&a, &b]\n .into_iter()\n- .filter(|it| {\n- let key = CanonicalItemUrl((*it).clone());\n- !defined_in_doc.contains(*it) && !body_exists(&key)\n- })\n- .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner())\n+ .filter(|it| !defined_in_doc.contains(*it) && !body_exists(it))\n+ .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner())\n .collect();\n if !missing_body.is_empty() {\n return Err((\ndiff --git a/server/src/html/editor.rs b/server/src/html/editor.rs\nindex ecdd226b1b17d5f11d1b79f99add5759ef918281..26d648d7c8d6e5d53ce5ed7a051b44856fca2fcf 100644\n--- a/server/src/html/editor.rs\n+++ b/server/src/html/editor.rs\n@@ -130,8 +130,8 @@ pub async fn editor_check(\n for s in &v.doc.statements {\n if let crate::dsl::Stmt::Vote { item1, item2, .. } = s {\n if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {\n- if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); }\n- if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); }\n+ if let Some(p) = a.parent() { parents.insert(p); }\n+ if let Some(p) = b.parent() { parents.insert(p); }\n }\n }\n }\ndiff --git a/types/src/paths.rs b/types/src/paths.rs\nindex 2950a7502255927583fbacdfd2adb700f0b0c221..839684dfa0ecdd6c2572e2d8132ecee4d5c41bf8 100644\n--- a/types/src/paths.rs\n+++ b/types/src/paths.rs\n@@ -3,6 +3,7 @@\n \n use std::borrow::Borrow;\n use std::fmt;\n+use std::ops::Deref;\n \n use serde::{Deserialize, Serialize};\n \n@@ -319,6 +320,14 @@ impl fmt::Display for GardenItemUrl {\n }\n }\n \n+impl Deref for GardenItemUrl {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n fn garden_href_string(item: &str, room_wire: &str) -> String {\n let room = room_wire.trim();\n if room.is_empty() || room == \"public\" {\n@@ -386,6 +395,14 @@ impl fmt::Display for ForumThreadUrl {\n }\n }\n \n+impl Deref for ForumThreadUrl {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n /// `~/a/b` style path for list UIs (paths index `path` field).\n #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n #[serde(transparent)]\n@@ -412,6 +429,14 @@ impl fmt::Display for TildeOntologyPath {\n }\n }\n \n+impl Deref for TildeOntologyPath {\n+ type Target = str;\n+\n+ fn deref(&self) -> &Self::Target {\n+ &self.0\n+ }\n+}\n+\n #[cfg(test)]\n mod tests {\n use super::*;\n@@ -452,6 +477,13 @@ mod tests {\n assert_eq!(c.tilde_segments(), Vec::<&str>::new());\n }\n \n+ #[test]\n+ fn garden_item_url_deref_to_str() {\n+ let g = GardenItemUrl::from_storage_str(\"https://slug.social/~/x\", \"public\");\n+ let s: &str = &*g;\n+ assert_eq!(s, \"https://slug.social/~/x\");\n+ }\n+\n #[test]\n fn garden_public_passthrough_https() {\n let u = \"https://slug.social/~/a/b\";\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}