You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [0366806e] Color rank rows by vote mass within each group, not list position. Min–max normalization keeps similar scores visually close while still using the full gradient as groups grow and absolute mass dilutes. Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index a58cbbee3490a08a625cb06df06848c59a615d65..4eff2e19ed4d303ff8e80c1eabd8a15b4990e643 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -264,12 +264,19 @@ pub fn scope_theme_style(parent: &ItemId) -> String { ) } -fn rank_row_style(parent: &ItemId, ordinal: usize, total: usize) -> String { - let t = if total <= 1 { - 0.0 - } else { - ordinal as f64 / (total - 1) as f64 - }; +/// Map vote mass to gradient position using the group's score range, not raw mass or +/// list position. Vote mass sums to 1 across the component, so absolute values dilute +/// as N grows; min–max within the visible list preserves similar scores → similar colors. +fn score_gradient_t(score: f64, min_score: f64, max_score: f64) -> f64 { + let spread = max_score - min_score; + if spread < 1e-9 { + return 0.5; + } + ((max_score - score) / spread).clamp(0.0, 1.0) +} + +fn rank_row_style(parent: &ItemId, score: f64, min_score: f64, max_score: f64) -> String { + let t = score_gradient_t(score, min_score, max_score); let base_hue = scope_base_hue(parent); let hue = (base_hue + 118.0 * t) % 360.0; let lightness = 0.74 - 0.34 * t; @@ -295,14 +302,15 @@ fn rank_list( highlighted: &HashSet, tree: &GlobalTree, ) -> Markup { - let group_len = items.len(); + let min_score = items.iter().map(|r| r.score).fold(f64::INFINITY, f64::min); + let max_score = items.iter().map(|r| r.score).fold(f64::NEG_INFINITY, f64::max); html! { @if !items.is_empty() { h3 class="rank-heading muted small" { (label) } ol class="rank-list" { @for (i, r) in items.iter().enumerate() { @let href = item_href(&r.item); - @let style = rank_row_style(parent, i, group_len); + @let style = rank_row_style(parent, r.score, min_score, max_score); @let class = rank_row_class(&r.item, highlighted); li class=(class) data-rank-item=(r.item.as_str()) @@ -517,19 +525,37 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons #[cfg(test)] mod tests { - use super::{rank_row_style, SORTER_UI_JS}; + use super::{rank_row_style, score_gradient_t, SORTER_UI_JS}; use crate::path_types::ItemId; #[test] - fn rank_row_style_gradients_per_group_not_globally() { + fn score_gradient_t_uses_group_range_not_absolute_mass() { + assert!((score_gradient_t(0.12, 0.08, 0.12) - 0.0).abs() < 1e-9); + assert!((score_gradient_t(0.08, 0.08, 0.12) - 1.0).abs() < 1e-9); + // Raw 12% mass would map near the dark end globally; within this group it's the top. + assert!(score_gradient_t(0.12, 0.08, 0.12) < score_gradient_t(0.12, 0.0, 1.0)); + } + + #[test] + fn score_gradient_t_similar_scores_similar_t() { + let a = score_gradient_t(0.41, 0.20, 0.60); + let b = score_gradient_t(0.40, 0.20, 0.60); + assert!((a - b).abs() < 0.05); + assert!((a - score_gradient_t(0.60, 0.20, 0.60)).abs() > 0.3); + } + + #[test] + fn score_gradient_t_tied_scores_neutral() { + assert!((score_gradient_t(0.25, 0.25, 0.25) - 0.5).abs() < 1e-9); + } + + #[test] + fn rank_row_style_same_inputs_same_color() { let parent = ItemId::opaque("test-scope"); - let first_in_four = rank_row_style(&parent, 0, 4); - let last_in_four = rank_row_style(&parent, 3, 4); - let first_in_two = rank_row_style(&parent, 0, 2); - let last_in_two = rank_row_style(&parent, 1, 2); - assert_eq!(first_in_four, first_in_two); - assert_eq!(last_in_four, last_in_two); - assert_ne!(first_in_four, last_in_four); + assert_eq!( + rank_row_style(&parent, 0.33, 0.20, 0.60), + rank_row_style(&parent, 0.33, 0.20, 0.60), + ); } #[test] Side B — contributor: tommy-mor Side B — commit message: [674964ef] refactor: Deref for href newtypes, CanonicalItemUrl through resolve_item - Implement Deref for GardenItemUrl, ForumThreadUrl, TildeOntologyPath - resolve_item returns CanonicalItemUrl; validate uses HashSet - compute_scope_rank_changes keys are CanonicalItemUrl; pair RPC uses Vec pool - pick_random_distinct_canonical; connectivity stats on &[CanonicalItemUrl] - Global rank unranked uses stored ids before GardenItemUrl mapping Made-with: Cursor Side B — unified diff (full patch): diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs index 03b3e77911ccd662bec8635345dafe2593cf242e..1b291db83df7364a026f2e147e0a29a70a399371 100644 --- a/server/src/api/helpers.rs +++ b/server/src/api/helpers.rs @@ -30,13 +30,13 @@ pub fn now_ms() -> i64 { t.as_millis() as i64 } -/// Resolve an item path as a first-class canonical path. -pub fn resolve_item(item: &str) -> Result { +/// Resolve DSL/user input to a stored canonical item id. +pub fn resolve_item(item: &str) -> Result { let canonical = canonicalize_item(item); if canonical.is_empty() { return Err(format!("empty item path: `{}`", item)); } - Ok(canonical) + Ok(CanonicalItemUrl(canonical)) } pub fn parse_parent_specs(parent: Option<&String>) -> Vec { @@ -94,7 +94,7 @@ pub fn paginate_rankings( (out_components, out_unranked) } -pub fn pick_random_distinct(items: &[String]) -> Option<(String, String)> { +pub fn pick_random_distinct_canonical(items: &[CanonicalItemUrl]) -> Option<(CanonicalItemUrl, CanonicalItemUrl)> { use rand::seq::SliceRandom; if items.len() < 2 { return None; @@ -123,15 +123,12 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo group.voted_pairs.contains(&(i, j)) } -pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats { +pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[CanonicalItemUrl]) -> ConnectivityStats { let n = pool.len(); let global_idxs: Vec> = pool .iter() - .map(|it| { - let key = CanonicalItemUrl(it.clone()); - group.item_to_idx.get(&key).copied() - }) + .map(|it| group.item_to_idx.get(it).copied()) .collect(); let present: Vec = global_idxs.iter().filter_map(|x| *x).collect(); diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index cf22cb0129366c3aed031bc86f3197a4321cb806..a10ce662105cff8fad949c6b83f7035ce79bed18 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -24,7 +24,7 @@ pub use auth::{ pub use helpers::{ api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, - parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path, + parse_parent_specs, pick_random_distinct_canonical, resolve_item, sha256_hex, vote_touches_path, }; pub use rpc::handle_rpc_batch; diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 5f7d50188f1381267402f2e57e671234ef5db2fd..de0955d0887d32740e1fd18365205c5bdb53c247 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -29,7 +29,7 @@ use crate::{ use super::auth::verify_bearer_principal; use super::helpers::{ compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs, - pick_random_distinct, resolve_item, vote_touches_path, + pick_random_distinct_canonical, resolve_item, vote_touches_path, }; use super::validate::{normalize_room_and_thread, validate_ingest_document}; @@ -146,21 +146,21 @@ fn authorize_room_read(reduced: &ReducerState, headers: &HeaderMap, room: &str) } fn compute_scope_rank_changes( - parent: &str, + parent: &CanonicalItemUrl, before: &crate::scope_rank::ChildrenRankings, after: &crate::scope_rank::ChildrenRankings, room_wire: &str, ) -> Option { - fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> { + fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap> { let mut map = HashMap::new(); for comp in &rankings.component_rankings { let total = comp.ranked.len(); for (i, item) in comp.ranked.iter().enumerate() { - map.insert(item.item.as_str().to_string(), Some(RankPosition { rank: i + 1, of: total })); + map.insert(item.item.clone(), Some(RankPosition { rank: i + 1, of: total })); } } for item in &rankings.unranked_items { - map.insert(item.as_str().to_string(), None); + map.insert(item.clone(), None); } map } @@ -168,7 +168,7 @@ fn compute_scope_rank_changes( let before_pos = build_positions(before); let after_pos = build_positions(after); - let all_items: std::collections::BTreeSet = before_pos.keys().cloned() + let all_items: std::collections::BTreeSet = before_pos.keys().cloned() .chain(after_pos.keys().cloned()) .collect(); @@ -184,7 +184,7 @@ fn compute_scope_rank_changes( }; if changed { changes.push(RankChange { - item: GardenItemUrl::from_storage_str(&item, room_wire), + item: GardenItemUrl::from_stored(&item, room_wire), before: b, after: a, }); @@ -203,11 +203,7 @@ fn compute_scope_rank_changes( }); Some(ScopeRankChanges { - parent: if parent.is_empty() { - "/".to_string() - } else { - GardenItemUrl::from_storage_str(parent, room_wire).into_inner() - }, + parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(), changes, }) } @@ -473,8 +469,8 @@ async fn rpc_post( for s in &v.doc.statements { if let dsl::Stmt::Vote { item1, item2, .. } = s { if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) { - if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); } - if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); } + if let Some(p) = a.parent() { parents.insert(p); } + if let Some(p) = b.parent() { parents.insert(p); } } } } @@ -525,7 +521,7 @@ async fn rpc_post( .filter_map(|p| { let before = pre_rankings.get(p)?; let after = crate::scope_rank::build_children_rankings(content, p); - compute_scope_rank_changes(p.as_str(), before, &after, &room_key) + compute_scope_rank_changes(p, before, &after, &room_key) }) .collect(); if v.is_empty() { None } else { Some(v) } @@ -638,8 +634,8 @@ async fn rpc_check( for s in &v.doc.statements { if let dsl::Stmt::Vote { item1, item2, .. } = s { if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) { - if let Some(p) = CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); } - if let Some(p) = CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); } + if let Some(p) = a.parent() { parents.insert(p); } + if let Some(p) = b.parent() { parents.insert(p); } } } } @@ -961,7 +957,7 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<& async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result { let scope = scope_from_room_wire(&room); let reduced_arc = state.reduced.clone(); - let pool: Vec = { + let pool: Vec = { let reduced = reduced_arc.read().await; let content = content_for_room(&reduced, &room); let tmp = if parent_path.trim().is_empty() { @@ -970,12 +966,11 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re Some(parent_path.clone()) }; let specs = parse_parent_specs(tmp.as_ref()); - let raw_pool: Vec = if specs.is_empty() { + if specs.is_empty() { content.ranking_group.idx_to_item.clone() } else { crate::scope_rank::resolve_scope(content, &specs) - }; - raw_pool.into_iter().map(|it| it.0).collect() + } }; if pool.len() < 2 { return Err(( @@ -983,31 +978,30 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re Some("add items via ingest".into()), )); } - let selected: Option<(String, String)> = { + let selected: Option<(CanonicalItemUrl, CanonicalItemUrl)> = { let mut reduced = reduced_arc.write().await; let content = reduced.content.entry(scope.clone()).or_default(); let group = &mut content.ranking_group; if group.idx_to_item.is_empty() { - pick_random_distinct(&pool) + pick_random_distinct_canonical(&pool) } else { let mut rng = rand::thread_rng(); - let idxs: Vec = pool.iter() - .filter_map(|it| { - let key = CanonicalItemUrl(it.clone()); - group.item_to_idx.get(&key).copied() - }) + let idxs: Vec = pool + .iter() + .filter_map(|it| group.item_to_idx.get(it).copied()) .collect(); let ranked = ranked_items_subset(group, &idxs, 10000, 1e-8); - let ranked_set: HashSet = ranked.iter().map(|r| r.item.as_str().to_string()).collect(); - let unsorted: Vec = pool.iter() + let ranked_set: HashSet = ranked.iter().map(|r| r.item.clone()).collect(); + let unsorted: Vec = pool + .iter() .filter(|it| !ranked_set.contains(*it)) .cloned() .collect(); - let mut pick: Option<(String, String)> = None; + let mut pick: Option<(CanonicalItemUrl, CanonicalItemUrl)> = None; if !unsorted.is_empty() { if let Some(left) = unsorted.choose(&mut rng).cloned() { - let mut candidates: Vec = if !ranked.is_empty() { - ranked.iter().map(|r| r.item.as_str().to_string()).collect() + let mut candidates: Vec = if !ranked.is_empty() { + ranked.iter().map(|r| r.item.clone()).collect() } else { pool.clone() }; @@ -1021,21 +1015,21 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re let a = ranked[i].item.as_str(); let b = ranked[i + 1].item.as_str(); if a != b && !is_pair_voted(group, a, b) { - pick = Some((a.to_string(), b.to_string())); + pick = Some((ranked[i].item.clone(), ranked[i + 1].item.clone())); break; } } if pick.is_none() { for _ in 0..64 { let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; }; - if a != b && !is_pair_voted(group, &a, &b) { + if a != b && !is_pair_voted(group, a.as_str(), b.as_str()) { pick = Some((a, b)); break; } } } } - pick.or_else(|| pick_random_distinct(&pool)) + pick.or_else(|| pick_random_distinct_canonical(&pool)) } }; let Some((left, right)) = selected else { @@ -1043,8 +1037,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re }; let reduced = reduced_arc.read().await; let content = content_for_room(&reduced, &room); - let left_key = CanonicalItemUrl(left.clone()); - let right_key = CanonicalItemUrl(right.clone()); + let left_key = left.clone(); + let right_key = right.clone(); let lb = content.item_bodies.get(&left_key).cloned(); let rb = content.item_bodies.get(&right_key).cloned(); let th: Vec = content @@ -1058,8 +1052,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re .collect(); let cs = compute_connectivity_stats(&content.ranking_group, &pool); Ok(RpcResult::Pair(PairResponse { - left: GardenItemUrl::from_storage_str(&left, &room), - right: GardenItemUrl::from_storage_str(&right, &room), + left: GardenItemUrl::from_stored(&left, &room), + right: GardenItemUrl::from_stored(&right, &room), left_body: lb, right_body: rb, threads: th, @@ -1554,7 +1548,7 @@ pub async fn handle_rpc_batch( for r in items { let pct = want_percent.then(|| ((r.score - bot) / range * 100.0).clamp(0.0, 100.0)); ranked.push(RankRow { - item: GardenItemUrl::from_storage_str(r.item.as_str(), &room), + item: GardenItemUrl::from_stored(&r.item, &room), score: r.score, percent: pct, }); @@ -1562,11 +1556,11 @@ pub async fn handle_rpc_batch( } let ranked_total = ranked.len(); - let mut unranked: Vec = content + let mut unranked: Vec = content .items .iter() .filter(|it| !group.item_to_idx.contains_key(*it)) - .map(|it| it.as_str().to_string()) + .cloned() .collect(); unranked.sort(); let unranked_total = unranked.len(); @@ -1574,7 +1568,7 @@ pub async fn handle_rpc_batch( let page: Vec = ranked .into_iter() .chain(unranked.into_iter().map(|it| RankRow { - item: GardenItemUrl::from_storage_str(&it, &room), + item: GardenItemUrl::from_stored(&it, &room), score: 0.0, percent: want_percent.then_some(0.0), })) @@ -1722,7 +1716,7 @@ pub async fn handle_rpc_batch( .filter(|p| !parents.contains(p.as_str())) .map(|p| GardenItemUrl::from_stored(p, &room)) .collect(); - paths.sort_by(|a, b| a.as_str().cmp(b.as_str())); + paths.sort(); line_ok(RpcResult::Leaves(LeavesResponse { paths })) } }, diff --git a/server/src/api/validate.rs b/server/src/api/validate.rs index a51c783ee9785569b5a44c0b1572471fe00d174b..3c7aa80fd485cf247231fe5c5c7e6fea62f22534 100644 --- a/server/src/api/validate.rs +++ b/server/src/api/validate.rs @@ -52,7 +52,7 @@ pub fn validate_ingest_document( }; let ts = super::helpers::now_ms(); - let mut defined_in_doc: HashSet = HashSet::new(); + let mut defined_in_doc: HashSet = HashSet::new(); for s in &doc.statements { match s { @@ -66,14 +66,14 @@ pub fn validate_ingest_document( let Some(body_text) = body else { return Err(( StatusCode::BAD_REQUEST, - format!("item missing body: {}", GardenItemUrl::from_storage_str(&item, room_wire)), + format!("item missing body: {}", GardenItemUrl::from_stored(&item, room_wire)), Some("items must be declared with bodies, e.g. `~/path/item { ... }`".to_string()), )); }; if body_text.trim().is_empty() { return Err(( StatusCode::BAD_REQUEST, - format!("item body is empty: {}", GardenItemUrl::from_storage_str(&item, room_wire)), + format!("item body is empty: {}", GardenItemUrl::from_stored(&item, room_wire)), Some("write at least one sentence inside `{ ... }`".to_string()), )); } @@ -102,11 +102,8 @@ pub fn validate_ingest_document( }; let missing: Vec = [&a, &b] .into_iter() - .filter(|it| { - let key = CanonicalItemUrl((*it).clone()); - !defined_in_doc.contains(*it) && !item_exists(&key) - }) - .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner()) + .filter(|it| !defined_in_doc.contains(*it) && !item_exists(it)) + .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner()) .collect(); if !missing.is_empty() { return Err(( @@ -120,11 +117,8 @@ pub fn validate_ingest_document( } let missing_body: Vec = [&a, &b] .into_iter() - .filter(|it| { - let key = CanonicalItemUrl((*it).clone()); - !defined_in_doc.contains(*it) && !body_exists(&key) - }) - .map(|it| GardenItemUrl::from_storage_str(it, room_wire).into_inner()) + .filter(|it| !defined_in_doc.contains(*it) && !body_exists(it)) + .map(|it| GardenItemUrl::from_stored(it, room_wire).into_inner()) .collect(); if !missing_body.is_empty() { return Err(( diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs index ecdd226b1b17d5f11d1b79f99add5759ef918281..26d648d7c8d6e5d53ce5ed7a051b44856fca2fcf 100644 --- a/server/src/html/editor.rs +++ b/server/src/html/editor.rs @@ -130,8 +130,8 @@ pub async fn editor_check( for s in &v.doc.statements { if let crate::dsl::Stmt::Vote { item1, item2, .. } = s { if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) { - if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&a).and_then(|c| c.parent()) { parents.insert(p); } - if let Some(p) = crate::path_types::CanonicalItemUrl::parse(&b).and_then(|c| c.parent()) { parents.insert(p); } + if let Some(p) = a.parent() { parents.insert(p); } + if let Some(p) = b.parent() { parents.insert(p); } } } } diff --git a/types/src/paths.rs b/types/src/paths.rs index 2950a7502255927583fbacdfd2adb700f0b0c221..839684dfa0ecdd6c2572e2d8132ecee4d5c41bf8 100644 --- a/types/src/paths.rs +++ b/types/src/paths.rs @@ -3,6 +3,7 @@ use std::borrow::Borrow; use std::fmt; +use std::ops::Deref; use serde::{Deserialize, Serialize}; @@ -319,6 +320,14 @@ impl fmt::Display for GardenItemUrl { } } +impl Deref for GardenItemUrl { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + fn garden_href_string(item: &str, room_wire: &str) -> String { let room = room_wire.trim(); if room.is_empty() || room == "public" { @@ -386,6 +395,14 @@ impl fmt::Display for ForumThreadUrl { } } +impl Deref for ForumThreadUrl { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + /// `~/a/b` style path for list UIs (paths index `path` field). #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -412,6 +429,14 @@ impl fmt::Display for TildeOntologyPath { } } +impl Deref for TildeOntologyPath { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + #[cfg(test)] mod tests { use super::*; @@ -452,6 +477,13 @@ mod tests { assert_eq!(c.tilde_segments(), Vec::<&str>::new()); } + #[test] + fn garden_item_url_deref_to_str() { + let g = GardenItemUrl::from_storage_str("https://slug.social/~/x", "public"); + let s: &str = &*g; + assert_eq!(s, "https://slug.social/~/x"); + } + #[test] fn garden_public_passthrough_https() { let u = "https://slug.social/~/a/b";