diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter index 3fa3f055e3f0a64dde1c0318193ce80369a7f67f..d3dcab4d18dc82d044ad9d22c7a37c7a6a92534f 100644 --- a/cli/GUIDE.sorter +++ b/cli/GUIDE.sorter @@ -204,7 +204,7 @@ garden children [path ...] [--aspect SLUG] Ranked children under path(s) garden pair Suggest a comparison pair under path + relevant threads. garden matchup Vote history for item with thread per vote. garden history Rank history for an item (position changes over time). -garden rank [--limit N] [--offset N] [--percent] [--aspect SLUG] Global flat ranking, or root-parent aspect group. +garden rank [--limit N] [--offset N] [--percent] [--aspect SLUG] [--all] Global flat ranking (tilde ontology; --all adds external imports), or root-parent aspect group. forum list List ~10 most active threads (bump-ordered) forum show View thread posts (tag without #; quote if needed) diff --git a/cli/src/main.rs b/cli/src/main.rs index 9b92aab2f5a171ec76bff8a9ba1c9435ac1e3b8f..052f3d2b4bd90d18d7340455d78b7f6e6052744c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -342,13 +342,15 @@ enum GardenCmd { json: bool, }, - /// Global ranking — all items across every scope, grouped by disconnected component. + /// Global ranking — tilde ontology across every scope, grouped by disconnected component. /// /// Components are largest-first; scores and percentages are comparable only within a component. + /// External imports are excluded unless `--all` is given. /// /// Examples: /// slugsocial public garden rank /// slugsocial public garden rank --limit 20 --offset 40 --percent + /// slugsocial public garden rank --all Rank { /// Max items to return (default: 50, max: 500) #[arg(long, default_value = "50")] @@ -359,6 +361,9 @@ enum GardenCmd { /// Show normalized score within each component (top item = 100%, unranked = 0%) #[arg(long)] percent: bool, + /// Include external imports alongside the tilde ontology + #[arg(long)] + all: bool, /// Rank the ontology-root electorate under this aspect (GetGardenRank). #[arg(long, value_name = "SLUG")] aspect: Option, @@ -556,7 +561,7 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) { println!(); } -fn print_global_rank_response(resp: &GlobalRankResponse) { +fn print_global_rank_response(resp: &GlobalRankResponse, include_all: bool) { let show_percent = resp .components .iter() @@ -569,7 +574,12 @@ fn print_global_rank_response(resp: &GlobalRankResponse) { .sum(); let shown_total = shown_ranked + resp.unranked_items.len(); println!( - "global rank (showing {}-{} of {} ranked + {} unranked)", + "global rank ({}) (showing {}-{} of {} ranked + {} unranked)", + if include_all { + "all items" + } else { + "tilde ontology" + }, resp.offset + 1, resp.offset + shown_total, resp.ranked_total, @@ -1207,6 +1217,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { percent, json, aspect, + all, } => { if let Some(aspect) = aspect { let batch = send_rpc( @@ -1244,6 +1255,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { limit: Some(limit), offset: Some(offset), percent: Some(percent), + all: all.then_some(true), }], ) .await?; @@ -1252,7 +1264,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(&resp)?); } else { - print_global_rank_response(resp); + print_global_rank_response(resp, all); } } _ => return Err(anyhow!("unexpected RPC result")), diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 0ed174c806a64f39c55dd3c6be8e6f40ad0abf85..5417d0494986a7a5fb84dadb648947d24b0628a8 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -1417,6 +1417,7 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand limit, offset, percent, + all, } => { let reduced = state.reduced.read().await; if let Err((e, h)) = authorize_room_read(&reduced, headers, &room) { @@ -1429,7 +1430,17 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand let want_percent = percent.unwrap_or(false); let content = content_for_room(&reduced, &room); let all_items: Vec = content.items.iter().cloned().collect(); - let rankings = crate::scope_rank::build_rankings_for_item_set(content, &all_items); + // Default scope is the tilde ontology: bulk-imported external + // items would otherwise dominate every global component. + let items: Vec = if all.unwrap_or(false) { + all_items + } else { + all_items + .into_iter() + .filter(|i| i.tilde_tail().is_some()) + .collect() + }; + let rankings = crate::scope_rank::build_rankings_for_item_set(content, &items); let ranked_total: usize = rankings .component_rankings .iter() diff --git a/server/tests/integration_rpc.rs b/server/tests/integration_rpc.rs index fb777e95eed8e33161cb3cf3d445a4128d513cbb..79ed1671b556f96d34c7a832d6baa1110d4f3393 100644 --- a/server/tests/integration_rpc.rs +++ b/server/tests/integration_rpc.rs @@ -272,6 +272,74 @@ async fn test_rank_endpoint() { ); } +#[tokio::test] +async fn test_global_rank_defaults_to_tilde_ontology() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::new(); + let seed = serde_json::json!([{ + "Post": { + "room": "public", + "thread_tag": "ns", + "delegate": "00000000-0000-0000-0000-000000000000:test:local/test", + "text": "~/nslang {scoped}\n~/nslang2 {scoped too}\n{because}\n~/nslang 2:1 ~/nslang2\n\ + https://example.com/a { imported a }\nhttps://example.com/b { imported b }\n\ + { why }\nhttps://example.com/a 2:1 https://example.com/b\n", + "return_rank_diff": false + } + }]); + let seeded = rpc_batch(&client, addr, Some(&test_bearer()), seed).await; + assert_eq!(seeded["results"][0]["ok"], true); + + let flat_items = |body: &serde_json::Value| -> Vec { + let global = &body["results"][0]["result"]["GlobalRank"]; + let mut items: Vec = global["components"] + .as_array() + .unwrap() + .iter() + .flat_map(|c| c["ranking"].as_array().unwrap().iter()) + .map(|r| r["item"].as_str().unwrap().to_string()) + .collect(); + items.extend( + global["unranked_items"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap().to_string()), + ); + items + }; + + let default_body = rpc_batch( + &client, + addr, + None, + serde_json::json!([{ "GetGlobalRank": { "room": "public" } }]), + ) + .await; + let default_items = flat_items(&default_body); + assert!( + default_items.iter().any(|i| i.ends_with("/~/nslang")), + "tilde items present by default: {default_items:?}" + ); + assert!( + default_items.iter().all(|i| !i.contains("example.com")), + "external imports excluded by default: {default_items:?}" + ); + + let all_body = rpc_batch( + &client, + addr, + None, + serde_json::json!([{ "GetGlobalRank": { "room": "public", "all": true } }]), + ) + .await; + let all_items = flat_items(&all_body); + assert!( + all_items.iter().any(|i| i.contains("example.com")), + "external imports included with all=true: {all_items:?}" + ); +} + #[tokio::test] async fn test_check_endpoint_does_not_commit() { let (addr, tmp, _log, _handle) = create_test_server().await; diff --git a/types/src/lib.rs b/types/src/lib.rs index 0485ba1fc4e2888764ee73db6ba1107b3a72e504..856a0d3d74fc3114e129ca0438f45d91ea095a2a 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -42,7 +42,9 @@ pub struct RankRow { pub percent: Option, } -/// Paginated global ranking across all items, grouped by disconnected component. +/// Paginated global ranking, grouped by disconnected component. Scoped to the +/// tilde ontology unless the request sets `all` (which pulls in external +/// imports too — thousands of resolver items that otherwise drown the garden). #[derive(Debug, Serialize, Deserialize)] pub struct GlobalRankResponse { /// Total ranked items (have at least one vote connecting them to another item). @@ -468,6 +470,9 @@ pub enum RpcCommand { offset: Option, #[serde(default)] percent: Option, + /// Include external imports alongside the tilde ontology (default: tilde only). + #[serde(default)] + all: Option, }, GetPair { room: String,