diff --git a/cli/src/main.rs b/cli/src/main.rs index dd2b007f3193bb0807b5fe9faf0aa74e7ebea3df..0182aeed8492c5bbd632257daff942a871ad9b98 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -93,11 +93,11 @@ enum ScopedCmd { /// /// Examples: /// - /// npx slugsocial public forum list + /// slugsocial public forum list /// - /// npx slugsocial public forum show languages + /// slugsocial public forum show languages /// - /// npx slugsocial public forum post languages --delegate 'uuid:rig:model' << 'EOF' + /// slugsocial public forum post languages --delegate 'uuid:rig:model' << 'EOF' /// … /// EOF Forum { @@ -125,7 +125,7 @@ enum ScopedCmd { json: bool, }, - /// List principals granted access in this room (requires View or Manage) + /// List principals granted access in a private room (requires View or Manage) Audit { #[arg(long)] json: bool, @@ -166,9 +166,9 @@ enum Command { /// Requires your saved bearer token; an explicit delegate must be bound to your account. /// /// Examples: - /// npx slugsocial feed - /// npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5 - /// npx slugsocial feed --since 2026-01-01 + /// slugsocial feed + /// slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5 + /// slugsocial feed --since 2026-01-01 Feed { /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post. #[arg(value_name = "DELEGATE", env = "SLUG_DELEGATE")] @@ -188,8 +188,8 @@ enum Command { /// Search items, threads, and posts /// /// Examples: - /// npx slugsocial search counting - /// npx slugsocial search "structural editing" + /// slugsocial search counting + /// slugsocial search "structural editing" Search { /// Search query #[arg(value_name = "QUERY")] @@ -333,13 +333,13 @@ enum GardenCmd { json: bool, }, - /// Global ranking — all items across every scope, flat and paginated. + /// Global ranking — all items across every scope, grouped by disconnected component. /// - /// Ranked items appear first (descending score), then unranked items (alphabetical). + /// Components are largest-first; scores and percentages are comparable only within a component. /// /// Examples: - /// npx slugsocial public garden rank - /// npx slugsocial public garden rank --limit 20 --offset 40 --percent + /// slugsocial public garden rank + /// slugsocial public garden rank --limit 20 --offset 40 --percent Rank { /// Max items to return (default: 50, max: 500) #[arg(long, default_value = "50")] @@ -347,7 +347,7 @@ enum GardenCmd { /// Skip first N items (for pagination) #[arg(long, default_value = "0")] offset: usize, - /// Show normalized score as a percent (top item = 100%, unranked = 0%) + /// Show normalized score within each component (top item = 100%, unranked = 0%) #[arg(long)] percent: bool, /// Output as JSON for agent parsing @@ -505,30 +505,31 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) { } fn print_global_rank_response(resp: &GlobalRankResponse) { - let show_percent = resp.items.iter().any(|r| r.percent.is_some()); + let show_percent = resp.components.iter().flat_map(|component| &component.ranking).any(|row| row.percent.is_some()); + let shown_ranked: usize = resp.components.iter().map(|component| component.ranking.len()).sum(); + let shown_total = shown_ranked + resp.unranked_items.len(); println!( "global rank (showing {}-{} of {} ranked + {} unranked)", resp.offset + 1, - resp.offset + resp.items.len(), + resp.offset + shown_total, resp.ranked_total, resp.unranked_total, ); - for (i, r) in resp.items.iter().enumerate() { - let rank = resp.offset + i + 1; - if r.score == 0.0 && r.percent.is_none_or(|p| p == 0.0) && rank > resp.ranked_total { - println!(" - {:<40} (unranked)", r.item); - } else if show_percent { - println!( - "{:>4}. {:<40} {:>6.1}% ({:.6})", - rank, - r.item, - r.percent.unwrap_or(0.0), - r.score, - ); - } else { - println!("{:>4}. {:<40} {:.6}", rank, r.item, r.score); + let mut rank = resp.offset + 1; + for (component_index, component) in resp.components.iter().enumerate() { + println!("\ncomponent {} ({} items, {} pairs; scores are component-local)", component_index + 1, component.ranking.len(), component.pairs); + for row in &component.ranking { + if show_percent { + println!("{:>4}. {:<40} {:>6.1}% ({:.6})", rank, row.item, row.percent.unwrap_or(0.0), row.score); + } else { + println!("{:>4}. {:<40} {:.6}", rank, row.item, row.score); + } + rank += 1; } } + for item in &resp.unranked_items { + println!(" - {:<40} (unranked)", item); + } } /// Print rank response: each component's ranking, then unranked (one line per item). @@ -721,7 +722,7 @@ fn rpc_line_ok(line: &RpcLine) -> Result<&RpcResult> { line.result.as_ref().ok_or_else(|| anyhow!("rpc missing result")) } -const PRIVATE_ROOM_NEEDS_BEARER: &str = "needs bearer token, use npx slugsocial identity command"; +const PRIVATE_ROOM_NEEDS_BEARER: &str = "needs bearer token, use slugsocial identity command"; fn private_room_needs_bearer_error() -> anyhow::Error { anyhow!(PRIVATE_ROOM_NEEDS_BEARER) @@ -1263,7 +1264,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { ForumCmd::Graduate { tag, json } => { if room == "public" { return Err(anyhow!( - "forum graduate is only for private rooms; use `npx slugsocial private forum graduate `" + "forum graduate is only for private rooms; use `slugsocial private forum graduate `" )); } let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?; @@ -1352,6 +1353,11 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { } } ScopedCmd::Audit { json } => { + if room == "public" { + return Err(anyhow!( + "audit is only for private rooms; use `slugsocial private audit`" + )); + } let bearer = effective_bearer().ok_or_else(private_room_needs_bearer_error)?; let batch = send_rpc( &client, @@ -1475,7 +1481,7 @@ async fn run() -> Result<()> { // If no command provided, print the guide let Some(cmd) = cmd else { - print!("{}", include_str!("../GUIDE.sorter")); + print!("{}", include_str!("../GUIDE.sorter").replace("npx slugsocial", "slugsocial")); return Ok(()); }; @@ -1508,8 +1514,8 @@ async fn run() -> Result<()> { } else { println!("{room_id}"); println!(); - println!("Next: npx slugsocial private {room_id} forum post --delegate '…' …"); - println!(" npx slugsocial private {room_id} invite-link --caps view,post,vote"); + println!("Next: slugsocial private {room_id} forum post --delegate '…' …"); + println!(" slugsocial private {room_id} invite-link --caps view,post,vote"); } } _ => return Err(anyhow!("unexpected RPC result")), diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 5b1cc059f431123dfb7076421a07bc00881a95fc..dc73655060a494ab4b0b0293c6977ee8f9aa1329 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -18,9 +18,9 @@ use crate::{ events::{Event, Ingest, ThreadCapability}, identity::{parse_agent, parse_username}, path_types::ItemId, - ranking::{connected_components_from_voted_pairs, rank_partition, ranked_items_subset}, + ranking::ranked_items_subset, reducer::{scope_from_room_wire, ReducerState, ScopeId}, - scope_rank::suggest_next_pair_in_pool, + scope_rank::{comparable_items, suggest_next_pair_in_pool}, state::{AppState, InviteState}, write_cmd::WriteCmd, }; @@ -797,16 +797,17 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re let specs = parse_parent_specs(tmp.as_ref()); validate_garden_parent_scope_paths(content, &specs, false)?; - if specs.is_empty() { + let candidates = if specs.is_empty() { content.ranking_group.idx_to_item.clone() } else { crate::scope_rank::resolve_scope(content, &specs) - } + }; + comparable_items(content, candidates) }; if pool.len() < 2 { return Err(( - format!("need at least 2 items under parent /{}", parent_path.trim()), - Some("add items via ingest".into()), + format!("need at least 2 comparable items under parent /{}", parent_path.trim()), + Some("define at least 2 direct items with non-empty bodies; folder paths are scopes, not vote targets".into()), )); } let selected: Option<(ItemId, ItemId)> = { @@ -1175,7 +1176,9 @@ pub async fn handle_rpc_batch( Err((_, m)) => line_err(m, None), Ok(principal) => { let reduced = state.reduced.read().await; - if !reduced.rooms.contains(&room) { + if room == "public" { + line_err("audit is only available for private rooms", None) + } else if !reduced.rooms.contains(&room) { line_err("unknown room", None) } else { let can_audit = reduced.user_has_cap(&room, &principal, ThreadCapability::View) @@ -1293,55 +1296,32 @@ pub async fn handle_rpc_batch( let want_percent = percent.unwrap_or(false); let reduced = state.reduced.read().await; let content = content_for_room(&reduced, &room); - let group = &content.ranking_group; - let n = group.idx_to_item.len(); - let (mut comps, _) = connected_components_from_voted_pairs( - n, group.voted_pairs.iter().copied(), - ); - comps.sort_by_key(|b| std::cmp::Reverse(b.len())); - - let mut ranked: Vec = Vec::new(); - for items in rank_partition(group, &comps, 10000, 1e-8) { - let top = items.first().map(|r| r.score).unwrap_or(1.0); - let bot = items.last().map(|r| r.score).unwrap_or(0.0); - let range = (top - bot).max(1e-12); - 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_stored(&r.item, &room), - score: r.score, - percent: pct, - }); + let all_items: Vec = content.items.iter().cloned().collect(); + let rankings = crate::scope_rank::build_rankings_for_item_set(content, &all_items); + let ranked_total: usize = rankings.component_rankings.iter().map(|component| component.ranked.len()).sum(); + let unranked_total = rankings.unranked_items.len(); + let components: Vec = rankings.component_rankings.into_iter().map(|component| { + let top = component.ranked.first().map(|r| r.score).unwrap_or(1.0).max(1e-12); + RankComponent { + pairs: component.pairs, + ranking: component.ranked.into_iter().map(|ranked| RankRow { + item: GardenItemUrl::from_stored(&ranked.item, &room), + score: ranked.score, + percent: want_percent.then(|| (ranked.score / top * 100.0).clamp(0.0, 100.0)), + }).collect(), } - } - - let ranked_total = ranked.len(); - let mut unranked: Vec = content - .items - .iter() - .filter(|it| !group.item_to_idx.contains_key(*it)) - .cloned() - .collect(); - unranked.sort(); - let unranked_total = unranked.len(); - - let page: Vec = ranked - .into_iter() - .chain(unranked.into_iter().map(|it| RankRow { - item: GardenItemUrl::from_stored(&it, &room), - score: 0.0, - percent: want_percent.then_some(0.0), - })) - .skip(offset) - .take(limit) - .collect(); + }).collect(); + let unranked: Vec = rankings.unranked_items.into_iter() + .map(|item| GardenItemUrl::from_stored(&item, &room)).collect(); + let (components, unranked_items) = paginate_rankings(components, unranked, offset, Some(limit)); line_ok(RpcResult::GlobalRank(GlobalRankResponse { ranked_total, unranked_total, offset, limit, - items: page, + components, + unranked_items, })) } RpcCommand::GetPair { room, parent_path } => { diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs index 4651295d1df1b44d461e64de42d32adc17ddddf0..8e6e1a76e1932a43dd3ce06256c1515201ee41b9 100644 --- a/server/src/html/garden/vote.rs +++ b/server/src/html/garden/vote.rs @@ -25,7 +25,7 @@ use crate::{ middleware::canonical_view_url, path_types::ItemId, reducer::{ContentState, ScopeId}, - scope_rank::suggest_next_pair_in_pool, + scope_rank::{comparable_items, is_comparable_item, suggest_next_pair_in_pool}, state::AppState, }; @@ -312,6 +312,7 @@ pub(super) fn suggest_next_vote_pair( } else { Vec::new() }; + let pool = comparable_items(content, pool); if pool.len() < 2 { return None; } @@ -442,9 +443,14 @@ async fn vote_compare_inner( .get(pool) .map(|s| s.iter().cloned().collect()) .unwrap_or_default(); + let children = comparable_items(content, children); if children.len() < 2 { drop(reduced); - return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response(); + return ( + StatusCode::BAD_REQUEST, + "pool needs at least 2 direct items with bodies; folder paths are scopes, not vote targets", + ) + .into_response(); } let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None); drop(reduced); @@ -458,6 +464,13 @@ async fn vote_compare_inner( let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); + if !is_comparable_item(content, &left) || !is_comparable_item(content, &right) { + return ( + StatusCode::BAD_REQUEST, + "comparison items must be defined with non-empty bodies; folder paths are scopes, not vote targets", + ) + .into_response(); + } let viewer = optional_principal(&headers, &jar, &reduced); let logged_in = viewer.is_some(); let can_post = match &nav.scope() { diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs index 0dd3590a9ef6f56b203a68b20721f2cb44e6dca0..84fcdb7431199624e71be86e593386bd9351586c 100644 --- a/server/src/scope_rank.rs +++ b/server/src/scope_rank.rs @@ -46,6 +46,24 @@ pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec { out } +/// Whether an item is eligible for comparison. Organizational path segments may be valid scopes +/// without being defined items, so they must not enter a vote pair. +pub fn is_comparable_item(content: &ContentState, item: &ItemId) -> bool { + content.items.contains(item) + && content + .item_bodies + .get(item) + .is_some_and(|body| !body.trim().is_empty()) +} + +/// Retain only direct scope members that can actually be submitted as vote targets. +pub fn comparable_items(content: &ContentState, items: Vec) -> Vec { + items + .into_iter() + .filter(|item| is_comparable_item(content, item)) + .collect() +} + /// Resolve scope specs recursively up to `depth` levels deep. /// depth=1 is equivalent to resolve_scope (direct children only). /// depth=2 includes grandchildren, etc. @@ -343,6 +361,17 @@ mod tests { assert!(out.contains(&ItemId::parse("https://slug.social/b/2").unwrap())); } + #[test] + fn comparable_items_excludes_bodyless_organizational_nodes() { + let folder = ItemId::parse("~/models/anthropic").unwrap().normalized_storage(); + let leaf = ItemId::parse("~/models/claude").unwrap().normalized_storage(); + let mut content = content_with_children(&[]); + content.items.insert(leaf.clone()); + content.item_bodies.insert(leaf.clone(), "A model.".into()); + + assert_eq!(comparable_items(&content, vec![folder, leaf.clone()]), vec![leaf]); + } + #[test] fn suggest_next_pair_skips_current_and_voted_pairs() { let mut group = crate::reducer::GroupState::new(); diff --git a/test/integration.clj b/test/integration.clj index 60b80351ed5e8991c4d745933ede26c1380c1488..179e5f6b1f48f06ec074085fade14a61800e1012 100644 --- a/test/integration.clj +++ b/test/integration.clj @@ -174,7 +174,7 @@ (bind private-post-json (json/parse-string (:out private-post-result) true)) (is (:ok private-post-json) "private forum post ok=true") - (bind private-needs-bearer-hint "needs bearer token, use npx slugsocial identity command") + (bind private-needs-bearer-hint "needs bearer token, use slugsocial identity command") (bind show-no-token (common/run-cli cli-bin base-url ["private" private-room-id "forum" "show" private-thread "--json"])) (is (not (zero? (:exit show-no-token))) @@ -183,7 +183,7 @@ (is (not (str/blank? show-no-token-combined)) "private forum show without token must not be completely silent (stdout+stderr)") (is (str/includes? show-no-token-combined private-needs-bearer-hint) - "private forum show without token must mention identity / bearer (npx slugsocial hint)") + "private forum show without token must mention identity / bearer command") (bind show-bad-token (common/run-cli cli-bin base-url ["private" private-room-id "forum" "show" private-thread "--json"] diff --git a/types/src/lib.rs b/types/src/lib.rs index 8435ef8b19318b0acb9ba99ed10202cc6d80b157..2f2f06ca0ea79a75b09e386af62e54ec1864ca69 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -37,12 +37,12 @@ pub struct ApiError { pub struct RankRow { pub item: GardenItemUrl, pub score: f64, - /// Normalized score as a percentage of the top item (0–100). Present when ?percent=true. + /// Normalized score within this connected component (0–100). Present when ?percent=true. #[serde(skip_serializing_if = "Option::is_none")] pub percent: Option, } -/// Flat, paginated global ranking across all items regardless of scope. +/// Paginated global ranking across all items, grouped by disconnected component. #[derive(Debug, Serialize, Deserialize)] pub struct GlobalRankResponse { /// Total ranked items (have at least one vote connecting them to another item). @@ -53,8 +53,11 @@ pub struct GlobalRankResponse { pub offset: usize, /// Pagination limit applied. pub limit: usize, - /// The page of items: ranked items first (descending score), then unranked (alphabetical). - pub items: Vec, + /// Ranked components, ordered largest-first. Scores and percentages are comparable within, + /// but not across, components. + pub components: Vec, + /// Unranked items included in this page after the ranked components. + pub unranked_items: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -436,7 +439,7 @@ pub enum RpcCommand { #[serde(default = "default_invite_max_uses")] max_uses: usize, }, - /// List principals granted access in a room (requires View or Manage). + /// List principals granted access in a private room (requires View or Manage). RoomAudit { room: String, },