{"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[8d8230d1] reddit\n\nSide A — unified diff (full patch):\ndiff --git a/.gitignore b/.gitignore\nindex 4c7073f9fac0c30fd2050d79a60ef447af58ebeb..ada462e900d24a3a6d08165d158c80f79c35a5a5 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -7,3 +7,4 @@\n data/\n repomix-output.xml\n dev-data/\n+.env\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 7906a8547d56b8e6a48ef59c37aa82a8510fdee9..4677fedcb45292eebebe7e9cf6ce2f5738f18ddf 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -16,6 +16,7 @@ tower = \"0.5\"\n tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\n tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n+reqwest = { version = \"0.12\", features = [\"json\"] }\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 2864407ed6e8ec284a1dc663acf1805534566b62..df6505021d9f446c2b453e20e3eb3cf696a111f9 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -272,5 +272,16 @@ pub async fn home(State(state): State, uri: Uri) -> impl IntoResponse\n \n pub async fn browse(State(state): State, uri: Uri) -> impl IntoResponse {\n let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());\n+ if item.as_str().starts_with(\"reddit.com\") {\n+ let needs_fetch = {\n+ let tree = state.tree.read().await;\n+ tree.get(&item)\n+ .map(|n| n.data.is_none())\n+ .unwrap_or(true)\n+ };\n+ if needs_fetch {\n+ state.reddit.request_fetch(item.clone());\n+ }\n+ }\n item_page(state, uri, item).await\n }\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex d203dca09245daf869b3aa942898447700ae69fb..90053ad03b1d7c8e94f325dd4ee64c2b4f7da900 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -1,4 +1,12 @@\n-//! Reddit API import (async, decoupled from UI request path).\n+//! Reddit API import via a single background worker (rate limits, dedup, backoff).\n+\n+use std::collections::{HashMap, HashSet};\n+use std::sync::Arc;\n+use std::time::{Duration, Instant};\n+\n+use reqwest::{header, Client, StatusCode};\n+use serde::Deserialize;\n+use tokio::sync::{mpsc, RwLock};\n \n use crate::{\n path_types::ItemId,\n@@ -10,12 +18,401 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {\n tree.ensure_path(id);\n }\n \n-/// Placeholder for Reddit JSON import. Returns entity data when implemented.\n-pub async fn fetch_reddit_entity(_id: &ItemId) -> Option {\n- None\n+pub struct RedditCommand {\n+ pub id: ItemId,\n+}\n+\n+#[derive(Clone)]\n+pub struct RedditBroker {\n+ tx: mpsc::Sender,\n+}\n+\n+#[derive(Clone)]\n+struct RedditCredentials {\n+ client_id: String,\n+ client_secret: String,\n+}\n+\n+struct OAuthToken {\n+ access_token: String,\n+ expires_at: Instant,\n+}\n+\n+impl RedditBroker {\n+ pub fn spawn(tree: Arc>, user_agent: &str) -> Self {\n+ let (tx, rx) = mpsc::channel(100);\n+\n+ let mut headers = header::HeaderMap::new();\n+ headers.insert(\n+ header::USER_AGENT,\n+ header::HeaderValue::from_str(user_agent).expect(\"valid user agent\"),\n+ );\n+\n+ let client = Client::builder()\n+ .default_headers(headers)\n+ .timeout(Duration::from_secs(15))\n+ .build()\n+ .expect(\"reqwest client\");\n+\n+ let creds = RedditCredentials::from_env();\n+ tokio::spawn(reddit_worker(rx, tree, client, creds));\n+\n+ Self { tx }\n+ }\n+\n+ /// Fire-and-forget: queue a fetch; worker updates the tree when done.\n+ pub fn request_fetch(&self, id: ItemId) {\n+ let _ = self.tx.try_send(RedditCommand { id });\n+ }\n+}\n+\n+impl RedditCredentials {\n+ fn from_env() -> Option {\n+ let client_id = std::env::var(\"REDDIT_CLIENT_ID\").ok()?;\n+ let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\").ok()?;\n+ if client_id.is_empty() || client_secret.is_empty() {\n+ return None;\n+ }\n+ Some(Self {\n+ client_id,\n+ client_secret,\n+ })\n+ }\n+}\n+\n+pub fn default_user_agent() -> String {\n+ std::env::var(\"REDDIT_USER_AGENT\").unwrap_or_else(|_| {\n+ \"web:sorter2.social:v0.0.1 (by /u/sorter2)\".to_string()\n+ })\n }\n \n-/// Apply fetched entity data to a node (called from async worker).\n-pub fn apply_entity(tree: &mut GlobalTree, id: &ItemId, data: EntityData) {\n- tree.set_entity_data(id, data);\n+async fn reddit_worker(\n+ mut rx: mpsc::Receiver,\n+ tree: Arc>,\n+ client: Client,\n+ creds: Option,\n+) {\n+ let mut in_flight = HashSet::new();\n+ let mut recently_fetched: HashMap = HashMap::new();\n+ let mut current_delay = Duration::from_secs(1);\n+ let mut oauth: Option = None;\n+ let cache_ttl = Duration::from_secs(300);\n+\n+ while let Some(cmd) = rx.recv().await {\n+ let now = Instant::now();\n+ recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);\n+\n+ if in_flight.contains(&cmd.id) || recently_fetched.contains_key(&cmd.id) {\n+ continue;\n+ }\n+\n+ in_flight.insert(cmd.id.clone());\n+ let fetch_id = cmd.id.clone();\n+\n+ tokio::time::sleep(current_delay).await;\n+\n+ if let Some(c) = &creds {\n+ oauth = ensure_oauth_token(&client, c, oauth.take()).await;\n+ }\n+\n+ let token = oauth.as_ref().map(|t| t.access_token.as_str());\n+ let use_oauth = token.is_some();\n+\n+ match do_fetch(&client, &fetch_id, use_oauth, token).await {\n+ Ok(FetchOutcome::Entity(data)) => {\n+ let mut w = tree.write().await;\n+ w.set_entity_data(&fetch_id, data);\n+ recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ current_delay = Duration::from_millis(600);\n+ }\n+ Ok(FetchOutcome::NotFound) => {\n+ recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ }\n+ Ok(FetchOutcome::RateLimited { reset_secs }) => {\n+ let wait = Duration::from_secs(reset_secs.max(1));\n+ tracing::warn!(\n+ \"Reddit rate limit for {}; sleeping {}s\",\n+ fetch_id,\n+ wait.as_secs()\n+ );\n+ tokio::time::sleep(wait).await;\n+ current_delay = (current_delay * 2).min(Duration::from_secs(60));\n+ }\n+ Err(e) => {\n+ tracing::warn!(\"Reddit fetch failed for {}: {}\", fetch_id, e);\n+ current_delay = (current_delay * 2).min(Duration::from_secs(60));\n+ }\n+ }\n+\n+ in_flight.remove(&fetch_id);\n+ }\n+}\n+\n+enum FetchOutcome {\n+ Entity(EntityData),\n+ NotFound,\n+ RateLimited { reset_secs: u64 },\n+}\n+\n+async fn ensure_oauth_token(\n+ client: &Client,\n+ creds: &RedditCredentials,\n+ existing: Option,\n+) -> Option {\n+ if let Some(t) = existing {\n+ if Instant::now() < t.expires_at - Duration::from_secs(60) {\n+ return Some(t);\n+ }\n+ }\n+\n+ let resp = client\n+ .post(\"https://www.reddit.com/api/v1/access_token\")\n+ .basic_auth(&creds.client_id, Some(&creds.client_secret))\n+ .form(&[(\"grant_type\", \"client_credentials\")])\n+ .send()\n+ .await;\n+\n+ let resp = match resp {\n+ Ok(r) => r,\n+ Err(e) => {\n+ tracing::warn!(\"Reddit OAuth token request failed: {e}\");\n+ return None;\n+ }\n+ };\n+\n+ if !resp.status().is_success() {\n+ tracing::warn!(\"Reddit OAuth token HTTP {}\", resp.status());\n+ return None;\n+ }\n+\n+ #[derive(Deserialize)]\n+ struct TokenResponse {\n+ access_token: String,\n+ expires_in: u64,\n+ }\n+\n+ let body: TokenResponse = match resp.json().await {\n+ Ok(b) => b,\n+ Err(e) => {\n+ tracing::warn!(\"Reddit OAuth token parse failed: {e}\");\n+ return None;\n+ }\n+ };\n+\n+ Some(OAuthToken {\n+ access_token: body.access_token,\n+ expires_at: Instant::now() + Duration::from_secs(body.expires_in),\n+ })\n+}\n+\n+async fn do_fetch(\n+ client: &Client,\n+ id: &ItemId,\n+ use_oauth: bool,\n+ bearer: Option<&str>,\n+) -> Result {\n+ let url = map_item_to_reddit_api(id, use_oauth);\n+ if url.is_empty() {\n+ return Ok(FetchOutcome::NotFound);\n+ }\n+\n+ let mut req = client.get(&url);\n+ if let Some(token) = bearer {\n+ req = req.bearer_auth(token);\n+ }\n+\n+ let resp = req.send().await.map_err(|e| e.to_string())?;\n+\n+ if resp.status() == StatusCode::TOO_MANY_REQUESTS {\n+ let reset = rate_limit_reset_secs(&resp);\n+ return Ok(FetchOutcome::RateLimited { reset_secs: reset });\n+ }\n+\n+ if resp.status() == StatusCode::SERVICE_UNAVAILABLE {\n+ return Err(\"Reddit unavailable (503)\".to_string());\n+ }\n+\n+ if !resp.status().is_success() {\n+ return Ok(FetchOutcome::NotFound);\n+ }\n+\n+ if rate_limit_remaining(&resp) == Some(0) {\n+ let reset = rate_limit_reset_secs(&resp);\n+ return Ok(FetchOutcome::RateLimited { reset_secs: reset });\n+ }\n+\n+ let bytes = resp.bytes().await.map_err(|e| e.to_string())?;\n+ Ok(parse_reddit_json(id, &bytes)\n+ .map(FetchOutcome::Entity)\n+ .unwrap_or(FetchOutcome::NotFound))\n+}\n+\n+fn rate_limit_remaining(resp: &reqwest::Response) -> Option {\n+ resp.headers()\n+ .get(\"x-ratelimit-remaining\")\n+ .and_then(|v| v.to_str().ok())\n+ .and_then(|s| s.parse::().ok())\n+ .map(|f| f.floor() as u64)\n+}\n+\n+fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 {\n+ resp.headers()\n+ .get(\"x-ratelimit-reset\")\n+ .and_then(|v| v.to_str().ok())\n+ .and_then(|s| s.parse::().ok())\n+ .map(|f| f.ceil() as u64)\n+ .unwrap_or(5)\n+}\n+\n+/// Map canonical item id to Reddit JSON API URL.\n+pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n+ let path = id.as_str();\n+ if !path.starts_with(\"reddit.com/\") && path != \"reddit.com\" {\n+ return String::new();\n+ }\n+\n+ let base = if oauth {\n+ \"https://oauth.reddit.com\"\n+ } else {\n+ \"https://www.reddit.com\"\n+ };\n+\n+ let segments: Vec<&str> = path.split('/').collect();\n+\n+ if let Some(i) = segments.iter().position(|&p| p == \"comments\") {\n+ if segments.len() > i + 1 {\n+ let api_path = segments[1..=i + 1].join(\"/\");\n+ return format!(\"{base}/{api_path}.json?raw_json=1\");\n+ }\n+ }\n+\n+ if segments.len() == 3 && segments[1] == \"r\" {\n+ return format!(\"{base}/r/{}/about.json?raw_json=1\", segments[2]);\n+ }\n+\n+ String::new()\n+}\n+\n+fn parse_reddit_json(id: &ItemId, bytes: &[u8]) -> Option {\n+ let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;\n+ let segments: Vec<&str> = id.as_str().split('/').collect();\n+\n+ if segments.iter().any(|&p| p == \"comments\") {\n+ parse_post_listing(&v)\n+ } else {\n+ parse_subreddit_about(&v)\n+ }\n+}\n+\n+fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n+ let data = v.get(\"data\")?;\n+ let title = data\n+ .get(\"title\")\n+ .or_else(|| data.get(\"display_name\"))\n+ .and_then(|t| t.as_str())?\n+ .to_string();\n+ let body_html = data\n+ .get(\"public_description_html\")\n+ .or_else(|| data.get(\"public_description\"))\n+ .and_then(|t| t.as_str())\n+ .map(|s| s.to_string());\n+ let thumb_url = data\n+ .get(\"icon_img\")\n+ .or_else(|| data.get(\"community_icon\"))\n+ .and_then(|t| t.as_str())\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_string());\n+\n+ Some(EntityData {\n+ title,\n+ author: None,\n+ body_html,\n+ thumb_url,\n+ })\n+}\n+\n+fn parse_post_listing(v: &serde_json::Value) -> Option {\n+ let listing = v.as_array()?.first()?;\n+ let child = listing\n+ .pointer(\"/data/children/0/data\")?;\n+ let title = child.get(\"title\")?.as_str()?.to_string();\n+ let author = child\n+ .get(\"author\")\n+ .and_then(|a| a.as_str())\n+ .filter(|a| *a != \"[deleted]\")\n+ .map(|s| s.to_string());\n+ let body_html = child\n+ .get(\"selftext_html\")\n+ .and_then(|t| t.as_str())\n+ .filter(|s| !s.is_empty())\n+ .map(|s| s.to_string());\n+ let thumb_url = child\n+ .get(\"thumbnail\")\n+ .and_then(|t| t.as_str())\n+ .filter(|s| s.starts_with(\"http\"))\n+ .map(|s| s.to_string());\n+\n+ Some(EntityData {\n+ title,\n+ author,\n+ body_html,\n+ thumb_url,\n+ })\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn map_subreddit_about_url() {\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ assert_eq!(\n+ map_item_to_reddit_api(&id, false),\n+ \"https://www.reddit.com/r/rust/about.json?raw_json=1\"\n+ );\n+ assert_eq!(\n+ map_item_to_reddit_api(&id, true),\n+ \"https://oauth.reddit.com/r/rust/about.json?raw_json=1\"\n+ );\n+ }\n+\n+ #[test]\n+ fn map_post_url() {\n+ let id =\n+ ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n+ assert_eq!(\n+ map_item_to_reddit_api(&id, false),\n+ \"https://www.reddit.com/r/amitheasshole/comments/1trnvdl.json?raw_json=1\"\n+ );\n+ }\n+\n+ #[test]\n+ fn map_non_reddit_empty() {\n+ let id = ItemId::opaque(\"example.com/foo\");\n+ assert!(map_item_to_reddit_api(&id, false).is_empty());\n+ }\n+\n+ #[test]\n+ fn parse_subreddit_fixture() {\n+ let json = r#\"{\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\",\"public_description\":\"systems\"}}\"#;\n+ let entity = parse_reddit_json(\n+ &ItemId::parse(\"reddit.com/r/rust\").unwrap(),\n+ json.as_bytes(),\n+ )\n+ .unwrap();\n+ assert_eq!(entity.title, \"Rust\");\n+ }\n+\n+ #[test]\n+ fn parse_post_fixture() {\n+ let json = r#\"[{\"kind\":\"Listing\",\"data\":{\"children\":[{\"kind\":\"t3\",\"data\":{\"title\":\"AITA\",\"author\":\"op\",\"selftext_html\":\"<p>hi</p>\",\"thumbnail\":\"https://b.thumbs.redditmedia.com/x.jpg\"}}]}}]\"#;\n+ let entity = parse_reddit_json(\n+ &ItemId::parse(\"reddit.com/r/x/comments/abc\").unwrap(),\n+ json.as_bytes(),\n+ )\n+ .unwrap();\n+ assert_eq!(entity.title, \"AITA\");\n+ assert_eq!(entity.author.as_deref(), Some(\"op\"));\n+ }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex cc1722f5a5bf4d415f2327ea585c488a15a75592..8c03aa60c15aee803a534439400b69935b1a3d84 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -5,9 +5,10 @@ use tokio::sync::RwLock;\n use crate::{\n event_log::EventLog,\n events::Event,\n+ journal::JournalClient,\n path_types::ItemId,\n+ reddit::{default_user_agent, RedditBroker},\n reducer::{GlobalTree, VoteData},\n- journal::JournalClient,\n views::ViewStore,\n };\n \n@@ -73,6 +74,7 @@ pub struct AppState {\n pub views: ViewStore,\n pub tree: Arc>,\n journal: JournalClient,\n+ pub reddit: RedditBroker,\n }\n \n impl AppState {\n@@ -112,6 +114,7 @@ impl AppState {\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n+ let reddit = RedditBroker::spawn(tree.clone(), &default_user_agent());\n \n Self {\n cfg: Arc::new(cfg),\n@@ -119,6 +122,7 @@ impl AppState {\n views,\n tree,\n journal,\n+ reddit,\n }\n }\n \n@@ -127,8 +131,11 @@ impl AppState {\n id: id.as_str().to_string(),\n };\n self.event_log.append(&event).await.map_err(|e| e.to_string())?;\n- let mut w = self.tree.write().await;\n- w.ensure_path(id);\n+ {\n+ let mut w = self.tree.write().await;\n+ w.ensure_path(id);\n+ }\n+ self.reddit.request_fetch(id.clone());\n Ok(())\n }\n \ndiff --git a/todo b/todo\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d196e8cb4cc80ccb95eeff01c73607d520e13212\n--- /dev/null\n+++ b/todo\n@@ -0,0 +1,7 @@\n+reddit import (only on explicit request)\n+reddit rendering\n+vote redering\n+pair chosing\n+nsfw gate\n+\n+logins (uuid user, two sides, oauths, and pseudonyms)\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":"~x-ai/grok-latest"}