diff --git a/agents.md b/agents.md index 0a37b3e52da06b064ac5a2039006e4f5e6a5ef18..59de8bb1c22a89c8ae0cbc473ca0bfe7a80d3e48 100644 --- a/agents.md +++ b/agents.md @@ -54,7 +54,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **Retired question pages:** `/q/:collection`, `/q/:collection/:aspect`, and the room-scoped twins are gone entirely — no routes serve them, so they 404 (no question index on `/` either). Pairwise judging lives at `/vote?pool=` / `/vote?left=&right=`; aspect groups still exist in the DSL/CLI and render on garden scope pages. -- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the least-judged tilde scope first (`pick_neediest_scope` in `server/src/html/garden/vote.rs`: lowest voted-pair density, ties prefer more members) with a 3-step intro, or an empty state when everything is judged. `/vote?pool=&aspect=` votes into that aspect group (hidden `aspect` input over the existing `$form` hole; invalid slugs 400). Garden aspect sections link their vote pool the same way. +- **`/vote` landing + aspects:** bare `GET /vote` (no pair) is the judgment entry point — it deals the most-compared open question first (`pick_landing_question` in `server/src/html/garden/vote.rs`: highest voted-pair count across canonical scopes and voted aspect groups, ties prefer more members then canonical), with a 3-step intro naming the scope (and `:aspect`), or an empty state when everything is judged. Popularity compounds deliberately: dealing a busy scope's stragglers grows it further. `/vote?pool=&aspect=` votes into that aspect group (hidden `aspect` input over the existing `$form` hole; invalid slugs 400). Garden aspect sections link their vote pool the same way. - **`ThreadGraduate` / `GraduateThread`:** Private-room forum threads with **Manage** can be published to the public site under the same tag. The writer replays non-redacted ingests into **`room: public`** (chronological order), then appends a durable **`ThreadGraduated`** marker. Graduated private threads show a banner linking to public **`/t/:tag`**, block further private posts, and cannot be graduated twice. CLI: **`npx slugsocial private forum graduate `**; RPC: **`ThreadGraduate`**. diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs index 82dc8c8a2a5e83bc4b726e91bd8bd92ba8cd161c..ff9875222f8954e2f80d4ce62100176dcdf08d60 100644 --- a/server/src/html/garden/tests.rs +++ b/server/src/html/garden/tests.rs @@ -10,7 +10,7 @@ use super::{ render::aspect_ranking_sections_markup, vote::{ canonical_edge_items, edge_vote_count_for_pair, edge_vote_entries_for_pair, - pick_neediest_scope, ratios_for_compare_page, sort_votes_for_compare_display, + pick_landing_question, ratios_for_compare_page, sort_votes_for_compare_display, suggest_next_vote_pair, vote_compare_item_card, vote_pool_href, }, }; @@ -844,10 +844,10 @@ fn pair_weight_label_counts_sugar_like_explicit() { assert_eq!(pair_weight_label(&sugar_st), "containment 1 · border 0"); } -/// The `/vote` landing deals the least-judged scope first and skips scopes -/// whose pairs are all voted. +/// The `/vote` landing deals the most-compared open question first and skips +/// scopes whose pairs are all voted. #[test] -fn pick_neediest_scope_prefers_unjudged_scope() { +fn pick_landing_question_serves_lone_open_scope() { let mut reduced = ReducerState::default(); apply_ingest( &mut reduced, @@ -860,18 +860,43 @@ fn pick_neediest_scope_prefers_unjudged_scope() { "~/fresh/x { ex }\n~/fresh/y { why }\n~/fresh/z { zed }\n", ); let content = content_for_garden_view(&reduced, &ScopeId::Public); - let (scope, voted, possible) = - pick_neediest_scope(content).expect("an unjudged scope"); + let needy = pick_landing_question(content).expect("an unjudged scope"); assert!( - scope.as_str().ends_with("fresh"), + needy.scope.as_str().ends_with("fresh"), "should deal ~/fresh, got {}", - scope.as_str() + needy.scope.as_str() ); - assert_eq!((voted, possible), (0, 3)); + assert_eq!(needy.aspect, None); + assert_eq!((needy.voted, needy.possible), (0, 3)); } +/// An aspect group hungrier than its canonical table wins the deal. #[test] -fn pick_neediest_scope_returns_none_when_all_judged() { +fn pick_landing_question_prefers_hungrier_aspect_group() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "~/tri/a { alpha }\n~/tri/b { beta }\n~/tri/c { gamma }\n\ + { ab }\n~/tri/a 2:1 ~/tri/b\n\ + { ac }\n~/tri/a 2:1 ~/tri/c\n\ + { bc }\n~/tri/b 2:1 ~/tri/c\n\ + :beauty {more beautiful}\n\ + { pretty }\n~/tri/a 2:1 ~/tri/b\n", + ); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let needy = pick_landing_question(content).expect("the aspect question"); + assert!( + needy.scope.as_str().ends_with("tri"), + "should deal ~/tri, got {}", + needy.scope.as_str() + ); + assert_eq!(needy.aspect.as_deref(), Some("beauty")); + assert_eq!((needy.voted, needy.possible), (1, 3)); +} + +#[test] +fn pick_landing_question_returns_none_when_all_judged() { let mut reduced = ReducerState::default(); apply_ingest( &mut reduced, @@ -879,5 +904,31 @@ fn pick_neediest_scope_returns_none_when_all_judged() { "~/done/a { alpha }\n~/done/b { beta }\n{ judged }\n~/done/a 2:1 ~/done/b\n", ); let content = content_for_garden_view(&reduced, &ScopeId::Public); - assert!(pick_neediest_scope(content).is_none()); + assert!(pick_landing_question(content).is_none()); +} + +/// Popularity beats neediness: a busy scope with stragglers outranks a fresh +/// scope nobody has touched. Winners keep winning. +#[test] +fn pick_landing_question_prefers_most_compared_scope() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "~/big/a { a }\n~/big/b { b }\n~/big/c { c }\n~/big/d { d }\n\ + { ab }\n~/big/a 2:1 ~/big/b\n\ + { ac }\n~/big/a 2:1 ~/big/c\n\ + { ad }\n~/big/a 2:1 ~/big/d\n\ + { bc }\n~/big/b 2:1 ~/big/c\n\ + { bd }\n~/big/b 2:1 ~/big/d\n", + ); + apply_ingest(&mut reduced, 2, "~/small/x { ex }\n~/small/y { why }\n"); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let deal = pick_landing_question(content).expect("an open question"); + assert!( + deal.scope.as_str().ends_with("big"), + "should deal popular ~/big, got {}", + deal.scope.as_str() + ); + assert_eq!((deal.voted, deal.possible), (5, 6)); } diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs index 4fdb244b2e5511a8ff86105a55f5b563146717e1..116a4cc51bcea769bb5458ce166951483dc41758 100644 --- a/server/src/html/garden/vote.rs +++ b/server/src/html/garden/vote.rs @@ -20,7 +20,7 @@ use crate::{ }, middleware::canonical_view_url, path_types::ItemId, - reducer::{ContentState, ScopeId}, + reducer::{ContentState, GroupState, ScopeId}, scope_rank::{comparable_items, suggest_next_pair_in_pool}, state::AppState, }; @@ -567,58 +567,116 @@ pub struct VoteCompareQuery { pub aspect: Option, } -/// Neediest scope for the `/vote` landing: lowest voted-pair density among -/// tilde scopes with ≥2 comparable members. Returns scope + voted + possible. -/// Fully-judged scopes are skipped; `None` means everything is judged. -pub(super) fn pick_neediest_scope(content: &ContentState) -> Option<(ItemId, usize, usize)> { - let group = &content.ranking_group; - // (density_num, density_den, scope): lowest density wins; ties prefer more - // members, then lex-smallest scope (iteration order is lex-sorted, and - // only strictly-better candidates replace the incumbent). - let mut best: Option<(usize, usize, usize, ItemId)> = None; +/// Showcase question for the `/vote` landing: the most-compared open +/// question across canonical scopes and voted aspect groups. Popularity is +/// judged by distinct voted pairs, so winners keep winning: dealing a +/// popular scope's stragglers grows it further. Fully-judged questions are +/// skipped (nothing left to deal); `None` means everything is judged. +pub(super) struct LandingQuestion { + pub scope: ItemId, + /// `None` = canonical ranking, `Some(slug)` = that aspect group. + pub aspect: Option, + pub voted: usize, + pub possible: usize, + pub members: usize, +} + +/// Density of voted pairs among `members` inside one vote graph. +fn voted_density( + item_to_idx: &std::collections::HashMap, + voted_pairs: &std::collections::HashSet<(usize, usize)>, + members: &[ItemId], +) -> (usize, usize) { + let possible = members.len() * (members.len() - 1) / 2; + let idx: Vec = members + .iter() + .filter_map(|m| item_to_idx.get(m).copied()) + .collect(); + let mut voted = 0usize; + for (i, &a) in idx.iter().enumerate() { + for &b in &idx[i + 1..] { + let key = if a < b { (a, b) } else { (b, a) }; + if voted_pairs.contains(&key) { + voted += 1; + } + } + } + (voted, possible) +} + +pub(super) fn pick_landing_question(content: &ContentState) -> Option { + // Most voted pairs wins; ties prefer more members, then canonical over an + // aspect, then lex order (iteration is lex-sorted and only strictly-better + // candidates replace the incumbent). + let mut best: Option = None; let mut scopes: Vec<&ItemId> = content .members_by_scope .keys() .filter(|id| matches!(id.tilde_tail(), Some(t) if !t.is_empty() && !t.contains('/'))) .collect(); scopes.sort_by(|a, b| a.as_str().cmp(b.as_str())); - for scope in scopes { + // (scope, aspect-group-or-canonical, aspect-slug-or-None), canonical first + // per scope so ties keep the canonical question. + let mut candidates: Vec<(&ItemId, Option<(&GroupState, String)>)> = Vec::new(); + for scope in &scopes { + candidates.push((scope, None)); + } + let mut aspect_keys: Vec<&(ItemId, String)> = content.aspect_groups.keys().collect(); + aspect_keys.sort(); + for (scope, slug) in aspect_keys { + if scopes.iter().any(|s| s.as_str() == scope.as_str()) { + candidates.push(( + scopes + .iter() + .find(|s| s.as_str() == scope.as_str()) + .expect("scope present"), + Some(( + content + .aspect_groups + .get(&(scope.clone(), slug.clone())) + .expect("aspect group present"), + slug.clone(), + )), + )); + } + } + for (scope, group_opt) in candidates { let members = comparable_items(content, content.members_of(scope)); if members.len() < 2 { continue; } - let possible = members.len() * (members.len() - 1) / 2; - let idx: Vec = members - .iter() - .filter_map(|m| group.item_to_idx.get(m).copied()) - .collect(); - let mut voted = 0usize; - for (i, &a) in idx.iter().enumerate() { - for &b in &idx[i + 1..] { - let key = if a < b { (a, b) } else { (b, a) }; - if group.voted_pairs.contains(&key) { - voted += 1; - } - } - } + let (group_idx, group_pairs) = match &group_opt { + None => ( + &content.ranking_group.item_to_idx, + &content.ranking_group.voted_pairs, + ), + Some((group, _)) => (&group.item_to_idx, &group.voted_pairs), + }; + let (voted, possible) = voted_density(group_idx, group_pairs, &members); if voted >= possible { continue; } - // Compare voted/possible without floats: a/b < c/d ⟺ a·d < c·b. - // strictly-less keeps the lex-first scope on exact ties; larger member - // count wins on equal density. + // Highest voted-pair count first; ties prefer more members (then the + // lex-first candidate, since iteration is sorted and only + // strictly-better replaces the incumbent). let take = match &best { None => true, - Some((bv, bp, bn, _)) => { - (voted * bp).cmp(&(bv * possible)) == std::cmp::Ordering::Less - || (voted * bp == bv * possible && members.len() > *bn) + Some(incumbent) => { + voted.cmp(&incumbent.voted) == std::cmp::Ordering::Greater + || (voted == incumbent.voted && members.len() > incumbent.members) } }; if take { - best = Some((voted, possible, members.len(), (*scope).clone())); + best = Some(LandingQuestion { + scope: (*scope).clone(), + aspect: group_opt.map(|(_, slug)| slug), + voted, + possible, + members: members.len(), + }); } } - best.map(|(voted, possible, _, scope)| (scope, voted, possible)) + best } /// Public pairwise vote UI — `/vote?left=&right=&thread=`. @@ -852,8 +910,9 @@ async fn render_compare_page( Html(page.into_string()).into_response() } -/// `GET /vote` with no pair: deal the neediest scope first — the judgment -/// entry point. First run is three steps; every later visit is one pair. +/// `GET /vote` with no pair: deal the neediest open question first — a scope +/// or one of its aspect groups. First run is three steps; every later visit +/// is one pair. async fn vote_landing( state: AppState, nav: ThreadNav, @@ -861,41 +920,60 @@ async fn vote_landing( jar: CookieJar, uri: Uri, ) -> axum::response::Response { - let (scope, voted, possible) = { + let needy = { let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); - match pick_neediest_scope(content) { - Some(pick) => pick, - None => { - drop(reduced); - return landing_empty_page(&state, &jar, &uri).await; - } - } + pick_landing_question(content).map(|n| (n.scope, n.aspect, n.voted, n.possible)) + }; + let Some((scope, aspect, voted, possible)) = needy else { + return landing_empty_page(&state, &jar, &uri).await; }; let pair = { let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); let members = comparable_items(content, content.members_of(&scope)); - suggest_next_pair_in_pool(&content.ranking_group, &members, None) + // Aspect groups share the scope electorate; canonical and aspect votes + // live in separate graphs, so deal from the group being judged. + let group = match &aspect { + None => &content.ranking_group, + Some(slug) => match content.aspect_group(&scope, slug) { + Some(group) => group, + None => { + drop(reduced); + return landing_empty_page(&state, &jar, &uri).await; + } + }, + }; + suggest_next_pair_in_pool(group, &members, None) }; let Some((left, right)) = pair else { return landing_empty_page(&state, &jar, &uri).await; }; + let scope_href = match &aspect { + None => item_href(scope.as_str(), &nav), + Some(slug) => format!("{}#aspect-{slug}", item_href(scope.as_str(), &nav)), + }; let intro = html! { header class="vote-landing" { h1 class="vote-landing-title" { "judge one pair" } p class="vote-landing-need" { + @if let Some(slug) = &aspect { + ":" (slug) " in " + } (item_display_path(scope.as_str())) - " has " + " — " (format!("{voted} of {possible}")) - " comparisons so far — your vote counts here more than anywhere else." + " pairs judged, the garden's most compared open question." } ol class="vote-landing-steps" { li { "compare the two items below" } li { "drag the slider, then write why (required)" } li { "post — your vote ranks them in " - a href=(item_href(scope.as_str(), &nav)) { + a href=(scope_href) { + @if let Some(slug) = &aspect { + ":" (slug) " in " + } (item_display_path(scope.as_str())) } } @@ -911,7 +989,7 @@ async fn vote_landing( left, right, Some(scope), - None, + aspect, None, Some(intro), )