diff --git a/agents.md b/agents.md index 26460d5dbfd075e103f274c6445ca464f66ab9e0..64d446d31c7102a75434d13cef85ab51bb584d11 100644 --- a/agents.md +++ b/agents.md @@ -59,6 +59,8 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **External URL wire form:** Canonical garden paths are **`/-/https://host/…`**. Legacy **`/-/host/…`** and collapsed **`/-/https:/host/…`** permanently redirect to the canonical form. Action `next` fields always use the canonical path. - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item. +- **Aspect sub-scopes:** A `:slug` / `:slug {prompt}` / bare `:` statement is parse-derived from ingest text (no event-schema change, no new write path). Votes under an aspect go to a ranking group keyed `(parent, aspect)`; the canonical `ranking_group` and `rank_position_cache` stay aspect-free. Garden parent/item pages (`render_scope_view`, public `~/`) and the `/try` editor preview list aspect sections **below** the canonical ranking when that parent has aspect votes (heading `:{slug}`, optional prompt). No `?aspect=` routes or default-aspect redirects. CLI `garden children --aspect` / `garden rank --aspect` and MCP `get_rank.aspect` are opt-in reads via `GetGardenRank.aspect`. Prompts live on `ContentState.aspect_prompts` per `(room scope, slug)`. + - **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection. **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate. @@ -75,7 +77,8 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma | **`RoomMintInvite` links** | **RAM only** | `AppState.invites` — not appended as `InviteMinted` today; **lost on restart** (`server/src/state.rs`, `server/src/api/rpc.rs`). Event types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted mint (`server/src/reducer.rs`). | | **OAuth / pending sessions** | **RAM only** | `AppState.pending_sessions` (`server/src/state.rs`, `server/src/api/auth.rs`) | | **External resolver cooldowns** | **RAM only** | `AppState.resolver_runs` — debounce/rate-limit guard for on-demand resolver buttons (`SLUG_GITHUB_RESOLVER_COOLDOWN_MS`, default 15s). Resolver results themselves are durable synthetic `Ingest` / `PostRedacted` events in `events.jsonl`. | -| **Rank-position memo** | **RAM only / derived** | `ContentState.rank_position_cache` — generation-keyed global ranks, component-local Rank Centrality scores, and per-parent scope ranks. Reuses one ingest’s “after” ordering as the next ingest’s “before”. Scores come from each connected component’s solve (never a whole-graph mix of disconnected clusters). Rebuilt lazily during event replay; never persisted. | +| **Rank-position memo** | **RAM only / derived** | `ContentState.rank_position_cache` — generation-keyed global ranks, component-local Rank Centrality scores, and per-parent scope ranks. Reuses one ingest’s “after” ordering as the next ingest’s “before”. Scores come from each connected component’s solve (never a whole-graph mix of disconnected clusters). Rebuilt lazily during event replay; never persisted. Canonical-only: aspect groups are not in this memo. | +| **Aspect groups / prompts** | **Derived** | `ContentState.aspect_groups` keyed `(parent, slug)` and `aspect_prompts` keyed by slug (per room `ContentState`). Rebuilt from DSL on ingest replay; never persisted. | | **Reducer projection** | **Derived** | Rebuilt from log on startup; not separately persisted | If you add a new ephemeral map or start persisting something that was RAM-only, **update this table and the code comments** (`server/src/state.rs` is a good anchor). diff --git a/cli/DSL.txt b/cli/DSL.txt index c9b12bf0edaf73ad252f0a202b7fe61e311df50a..b367c41d9f0e7d6783527c881798f00f2f1363d1 100644 --- a/cli/DSL.txt +++ b/cli/DSL.txt @@ -1,6 +1,6 @@ SLUG DSL REFERENCE -The Slug DSL mixes freeform prose with structured statements. Statements start with specific characters (`#`, `~`, or `http`/`https`). Everything else is prose. +The Slug DSL mixes freeform prose with structured statements. Statements start with specific characters (`#`, `~`, `:`, or `http`/`https`). Everything else is prose. Identity and routing are **not** in the document body: the human principal comes from the bearer token, the forum channel from `forum post ` (CLI) or request metadata (RPC/web), and the AI delegate from `--delegate` on CLI posts (`uuid:rig:provider/model`, no `@`). The web UI uses the same split (session + form fields). @@ -71,6 +71,17 @@ Comparisons: Lines that begin with `@` are not DSL statements; the parser treats them like ordinary prose (so old examples that used `@…` still parse as text, but identity belongs in request metadata, not the file). +```sorter +:beauty +:beauty { winner is more beautiful } +: +``` +An aspect is a named sub-question over the same electorate as a parent scope. A line that is exactly `:` plus a slug (`[a-z0-9_-]{1,64}`) — optional `{prompt}` body, same brace-masking as item bodies — sets the current aspect for all later votes in this document (lexical, top to bottom). Bare `:` resets to the canonical (no-aspect) ranking. Documents start in canonical mode; aspect state does not persist across documents. + +Aspects affect **votes only**. Item definitions (`~/path {body}`) after `:beauty` are still ordinary items. A line that starts with `:` but is not exactly `:` or `:` + valid slug (+ optional brace block) stays prose (`:)`, `: note`, `::x`, `:UPPER`, `:has space {x}`). Historical posts that used those lines keep their meaning. + +Votes under an aspect rank in a separate group keyed `(parent, aspect)`. The canonical ranking is unchanged. + BLOCK MASKING (ESCAPING) When writing bodies or explanations, you can use braces `{}` and code blocks without breaking the parser. The parser protects blocks in this hierarchy: diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter index 88b4cda8b772c9772818dea1a87d72cabce13ea0..5f6e2d887a5e5ca71f20bbd1b54cc8d156c86160 100644 --- a/cli/GUIDE.sorter +++ b/cli/GUIDE.sorter @@ -97,6 +97,12 @@ Shorthand: = means 1:1 (equal) Item bodies follow the item path. Vote explanations come first; the comparison is the verdict line. + +Aspects (named sub-questions over the same parent electorate): + :beauty + :beauty { winner is more beautiful } + : +Lexical, top-to-bottom. Sets the aspect for later votes in this document only. Bare `:` resets to the canonical ranking. Slug charset [a-z0-9_-]{1,64}; anything else starting with `:` stays prose (`:)`, `: note`, `::x`). Aspects affect votes only, not item definitions. Canonical ranking is unchanged. } You can write any prose in your posts. These won't be part of the garden but only the thread. @@ -186,11 +192,11 @@ Scoped groups (same under public and private): garden tree List every leaf path in the ontology. Full list; does not scale. garden body Item body + threads that mention it (path: e.g. languages/rust, no ~) -garden children [path ...] Ranked children under path(s). Multiple paths merge scopes. +garden children [path ...] [--aspect SLUG] Ranked children under path(s). `--aspect` is that parent's aspect group. 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] Global flat ranking (paginated). +garden rank [--limit N] [--offset N] [--percent] [--aspect SLUG] Global flat ranking, 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 9e78cb17189b0f465e5fa6e153d4e7c76c9daa23..f8b2a8be21fdf76c54cff8191056cc6eee5bc27c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -304,6 +304,9 @@ enum GardenCmd { /// Pass `all` (or `∞` / `inf`) for every descendant. #[arg(long, value_name = "N|all")] depth: Option, + /// Rank under this aspect slug instead of the canonical ranking. + #[arg(long, value_name = "SLUG")] + aspect: Option, /// Output as JSON for agent parsing #[arg(long)] json: bool, @@ -350,6 +353,9 @@ enum GardenCmd { /// Show normalized score within each component (top item = 100%, unranked = 0%) #[arg(long)] percent: bool, + /// Rank the ontology-root electorate under this aspect (GetGardenRank). + #[arg(long, value_name = "SLUG")] + aspect: Option, /// Output as JSON for agent parsing #[arg(long)] json: bool, @@ -534,6 +540,9 @@ fn print_global_rank_response(resp: &GlobalRankResponse) { /// Print rank response: each component's ranking, then unranked (one line per item). fn print_rank_response(resp: &RankResponse) { + if let Some(aspect) = &resp.aspect { + println!("aspect: :{aspect}"); + } for comp in &resp.components { for (i, r) in comp.ranking.iter().enumerate() { println!("{:>3}. {:<24} {:.6}", i + 1, r.item, r.score); @@ -546,7 +555,10 @@ fn print_rank_response(resp: &RankResponse) { fn print_check_rankings(rankings: &[CheckScopeRanking]) { for scope in rankings { - println!("scope: {}", scope.parent); + match &scope.aspect { + Some(aspect) => println!("scope: {} :{}", scope.parent, aspect), + None => println!("scope: {}", scope.parent), + } for comp in &scope.components { if scope.components.len() > 1 { println!("(component: {} pairs)", comp.pairs); @@ -974,7 +986,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { _ => return Err(anyhow!("unexpected RPC result")), } } - GardenCmd::Children { paths, depth, json } => { + GardenCmd::Children { paths, depth, json, aspect } => { let paths: Vec = paths .iter() .map(|p| normalize_ontology_path_input(p).map_err(anyhow::Error::msg)) @@ -996,6 +1008,7 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { offset: None, limit: None, percent: None, + aspect, }], ) .await?; @@ -1083,28 +1096,56 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { _ => return Err(anyhow!("unexpected RPC result")), } } - GardenCmd::Rank { limit, offset, percent, json } => { - let batch = send_rpc( - &client, - base, - scoped_read_bearer, - vec![RpcCommand::GetGlobalRank { - room: room.to_string(), - limit: Some(limit), - offset: Some(offset), - percent: Some(percent), - }], - ) - .await?; - match rpc_line_ok_scoped_read(&batch.results[0], room)? { - RpcResult::GlobalRank(resp) => { - if json { - println!("{}", serde_json::to_string_pretty(&resp)?); - } else { - print_global_rank_response(resp); + GardenCmd::Rank { limit, offset, percent, json, aspect } => { + if let Some(aspect) = aspect { + let batch = send_rpc( + &client, + base, + scoped_read_bearer, + vec![RpcCommand::GetGardenRank { + room: room.to_string(), + parent_path: "~".to_string(), + depth: None, + offset: Some(offset), + limit: Some(limit), + percent: Some(percent), + aspect: Some(aspect), + }], + ) + .await?; + match rpc_line_ok_scoped_read(&batch.results[0], room)? { + RpcResult::GardenRank(resp) => { + if json { + println!("{}", serde_json::to_string_pretty(&resp)?); + } else { + print_rank_response(&resp); + } } + _ => return Err(anyhow!("unexpected RPC result")), + } + } else { + let batch = send_rpc( + &client, + base, + scoped_read_bearer, + vec![RpcCommand::GetGlobalRank { + room: room.to_string(), + limit: Some(limit), + offset: Some(offset), + percent: Some(percent), + }], + ) + .await?; + match rpc_line_ok_scoped_read(&batch.results[0], room)? { + RpcResult::GlobalRank(resp) => { + if json { + println!("{}", serde_json::to_string_pretty(&resp)?); + } else { + print_global_rank_response(resp); + } + } + _ => return Err(anyhow!("unexpected RPC result")), } - _ => return Err(anyhow!("unexpected RPC result")), } } }, diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index c3e5ccd227f071bddb7d519f76b512cb963b25f3..f180d6ed45d6caa2bdd38658f20bf7b0da9df7b3 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -23,7 +23,7 @@ use crate::{ use super::auth::{parse_bearer, verify_bearer_principal}; use super::helpers::{ compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs, - pick_random_distinct_item_pair, resolve_item, validate_garden_parent_scope_paths, + pick_random_distinct_item_pair, validate_garden_parent_scope_paths, vote_touches_path, }; use super::validate::validate_ingest_document; @@ -221,14 +221,48 @@ fn build_rank_response_for_content( limit: Option, want_percent: bool, room_wire: &str, + aspect: Option, ) -> Result { let parent_owned = parent.map(|s| s.to_string()); let specs = parse_parent_specs(parent_owned.as_ref()); let is_global = parent.map(|p| p.trim() == "~").unwrap_or(false); + if let Some(ref slug) = aspect { + if !crate::dsl::is_valid_aspect_slug(slug) { + return Err(( + "invalid aspect".into(), + Some("aspect slugs are [a-z0-9_-]{1,64}".into()), + )); + } + if specs.len() > 1 { + return Err(( + "aspect ranking requires a single parent".into(), + None, + )); + } + } + validate_garden_parent_scope_paths(content, &specs, is_global)?; let depth = depth.max(1); - let rankings = if is_global { + let rankings = if let Some(ref slug) = aspect { + let parent_id = if is_global || specs.is_empty() { + ItemId::ontology_root() + } else { + ItemId::parse(&specs[0]).unwrap_or_else(|| ItemId::opaque(specs[0].clone())) + }; + let empty = crate::reducer::GroupState::new(); + let group = content + .aspect_group(&parent_id, slug) + .unwrap_or(&empty); + let items = if depth > 1 && !is_global && !specs.is_empty() { + crate::scope_rank::resolve_scope_recursive(content, &specs, depth) + } else if is_global || specs.is_empty() { + crate::scope_rank::resolve_scope(content, &[parent_id.as_str().to_string()]) + } else { + crate::scope_rank::resolve_scope(content, &specs) + }; + crate::scope_rank::build_rankings_for_group_and_items(group, &items) + } else if is_global { let all_items: Vec = content.items.iter().cloned().collect(); crate::scope_rank::build_rankings_for_item_set(content, &all_items) } else if specs.is_empty() { @@ -280,6 +314,7 @@ fn build_rank_response_for_content( Ok(RankResponse { components, unranked_items, + aspect, }) } @@ -414,7 +449,7 @@ async fn rpc_check( dsl::Stmt::Item { .. } => { required.insert(ThreadCapability::AddItem); } - dsl::Stmt::Prose { .. } => { + dsl::Stmt::Prose { .. } | dsl::Stmt::Aspect { .. } => { required.insert(ThreadCapability::Post); } } @@ -444,59 +479,7 @@ async fn rpc_check( let mut simulated = { reduced_arc.read().await.clone() }; simulated.apply_event(event); - let voted_parents: Vec = { - let mut parents: HashSet = HashSet::new(); - 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) = a.parent() { - parents.insert(p); - } - if let Some(p) = b.parent() { - parents.insert(p); - } - } - } - } - let mut out: Vec = parents.into_iter().collect(); - out.sort(); - out - }; - - let rankings: Vec = voted_parents - .iter() - .map(|parent| { - let scoped_content = simulated - .content_for_scope(&scope) - .unwrap_or_else(|| simulated.public()); - let scoped = crate::scope_rank::build_children_rankings(scoped_content, parent); - let components: Vec = scoped - .component_rankings - .into_iter() - .map(|comp| RankComponent { - pairs: comp.pairs, - ranking: comp - .ranked - .into_iter() - .map(|r| RankRow { - item: GardenItemUrl::from_stored(&r.item, &room_key), - score: r.score, - percent: None, - }) - .collect(), - }) - .collect(); - CheckScopeRanking { - parent: GardenItemUrl::from_stored(parent, &room_key).into_inner(), - components, - unranked_items: scoped - .unranked_items - .into_iter() - .map(|it| GardenItemUrl::from_stored(&it, &room_key)) - .collect(), - } - }) - .collect(); + let rankings = crate::offline::rankings_for_document(&simulated, &scope, &room_key, &v.doc); let check_next = if room_key == "public" { vec![ @@ -1039,6 +1022,7 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand offset, limit, percent, + aspect, } => { let reduced = state.reduced.read().await; if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { @@ -1058,6 +1042,7 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand limit, percent.unwrap_or(false), &room, + aspect.filter(|s| !s.trim().is_empty()), ) { Ok(r) => line_ok(RpcResult::GardenRank(r)), Err((e, h)) => line_err(e, h), @@ -1556,6 +1541,8 @@ pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand ratio_left, ratio_right, explanation, + aspect: None, + .. } = s { let a = canonicalize_item(&item1); diff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs index 8c76edccdaeed5690088f40665d9b7db78b969da..8ad39ba903a2eed1066d137b41fd49caf3d288bf 100644 --- a/server/src/api/write_actor.rs +++ b/server/src/api/write_actor.rs @@ -327,7 +327,7 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { dsl::Stmt::Item { .. } => { required.insert(ThreadCapability::AddItem); } - dsl::Stmt::Prose { .. } => { + dsl::Stmt::Prose { .. } | dsl::Stmt::Aspect { .. } => { required.insert(ThreadCapability::Post); } } @@ -361,7 +361,13 @@ pub async fn writer_actor(mut rx: mpsc::Receiver, state: AppState) { let voted_parent_scopes: Vec = { let mut parents: HashSet = HashSet::new(); for s in &v.doc.statements { - if let dsl::Stmt::Vote { item1, item2, .. } = s { + if let dsl::Stmt::Vote { + item1, + item2, + aspect: None, + .. + } = s + { if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) { if let Some(p) = a.parent() { parents.insert(p); diff --git a/server/src/dsl.rs b/server/src/dsl.rs index e19a09ffde60c0f617b74e20091c4d09e071acd1..9292c3645392891cd020b458dc1e04e473d94dbc 100644 --- a/server/src/dsl.rs +++ b/server/src/dsl.rs @@ -20,6 +20,11 @@ pub enum Stmt { ratio_right: i32, /// Required non-empty explanation (from leading `{ ... }`). explanation: String, + aspect: Option, + }, + Aspect { + slug: Option, + prompt: Option, }, Prose { text: String, @@ -290,6 +295,76 @@ fn is_item_name(s: &str) -> bool { true } +pub fn is_valid_aspect_slug(s: &str) -> bool { + let len = s.len(); + (1..=64).contains(&len) + && s.bytes() + .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) +} + +fn try_parse_aspect_line(stripped: &str, masker: &BlockMasker) -> Option { + if !stripped.starts_with(':') { + return None; + } + let rest = stripped[1..].trim_end(); + if rest.is_empty() { + return Some(Stmt::Aspect { + slug: None, + prompt: None, + }); + } + let bytes = rest.as_bytes(); + let mut slug_len = 0usize; + while slug_len < bytes.len() { + if bytes[slug_len..].starts_with(b"__BLOCK_") { + break; + } + if !matches!(bytes[slug_len], b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-') { + break; + } + slug_len += 1; + if slug_len > 64 { + return None; + } + } + if slug_len == 0 { + return None; + } + if slug_len == 64 { + if let Some(tail) = bytes.get(64..) { + if !tail.starts_with(b"__BLOCK_") + && tail + .first() + .is_some_and(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-')) + { + return None; + } + } + } + let slug = &rest[..slug_len]; + if !is_valid_aspect_slug(slug) { + return None; + } + let after = rest[slug_len..].trim_start(); + if after.is_empty() { + return Some(Stmt::Aspect { + slug: Some(slug.to_string()), + prompt: None, + }); + } + let (tok, end) = parse_block_token_at(after, 0)?; + if masker.block_kind(&tok) == Some(BlockKind::CodeFence) { + return None; + } + if !after[end..].trim().is_empty() { + return None; + } + Some(Stmt::Aspect { + slug: Some(slug.to_string()), + prompt: Some(masker.extract_body(&tok)), + }) +} + fn is_block_token(s: &str) -> bool { // "__BLOCK_" + 8 hex + "__" if !s.starts_with("__BLOCK_") || !s.ends_with("__") { @@ -598,6 +673,7 @@ fn parse_block_prefixed_statement( ratio_left, ratio_right, explanation, + aspect: None, }) } @@ -643,7 +719,9 @@ fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result, DslE let first = stripped.chars().next().unwrap(); match first { '#' => Err(DslError::Parse("not a DSL line".to_string())), - ':' => Err(DslError::Parse("leading ':' is not supported".to_string())), + ':' => try_parse_aspect_line(stripped, masker) + .map(|s| vec![s]) + .ok_or_else(|| DslError::Parse("not a DSL line".to_string())), '@' => Err(DslError::Parse("not a DSL line".to_string())), '_' => { let Some((tok, end)) = parse_block_token_at(stripped, 0) else { @@ -684,6 +762,7 @@ pub fn parse_full(text: &str) -> Result { let mut statements: Vec = Vec::new(); let mut prose_buffer: Vec<&str> = Vec::new(); let mut pending_block: Option = None; + let mut current_aspect: Option = None; let flush_prose = |buf: &mut Vec<&str>, out: &mut Vec, masker: &BlockMasker| { if buf.is_empty() { @@ -706,7 +785,11 @@ pub fn parse_full(text: &str) -> Result { || stripped.starts_with("https://") || stripped.starts_with("http://") { - statements.push(parse_block_prefixed_statement(tok, stripped, &masker)?); + let mut stmt = parse_block_prefixed_statement(tok, stripped, &masker)?; + if let Stmt::Vote { aspect, .. } = &mut stmt { + *aspect = current_aspect.clone(); + } + statements.push(stmt); pending_block = None; continue; } @@ -715,11 +798,20 @@ pub fn parse_full(text: &str) -> Result { )); } + if let Some(aspect_stmt) = try_parse_aspect_line(stripped, &masker) { + flush_prose(&mut prose_buffer, &mut statements, &masker); + if let Stmt::Aspect { slug, .. } = &aspect_stmt { + current_aspect = slug.clone(); + } + statements.push(aspect_stmt); + continue; + } + if !stripped.is_empty() && (stripped.starts_with("-/") || { let c = stripped.chars().next().unwrap(); - ":/!~_".contains(c) + "/!~_".contains(c) } || stripped.starts_with("https://") || stripped.starts_with("http://")) @@ -739,7 +831,13 @@ pub fn parse_full(text: &str) -> Result { } // Parse DSL line; DSL statements are not prose, so errors should propagate. + let start = statements.len(); statements.extend(parse_line(line, &masker)?); + for stmt in &mut statements[start..] { + if let Stmt::Vote { aspect, .. } = stmt { + *aspect = current_aspect.clone(); + } + } } else { prose_buffer.push(line); } @@ -925,7 +1023,8 @@ mod tests { item2: "~/b".to_string(), ratio_left: 3, ratio_right: 1, - explanation: "because".to_string() + explanation: "because".to_string(), + aspect: None, }] ); @@ -937,7 +1036,8 @@ mod tests { item2: "~/b".to_string(), ratio_left: 2, ratio_right: 1, - explanation: "because".to_string() + explanation: "because".to_string(), + aspect: None, }] ); @@ -949,7 +1049,8 @@ mod tests { item2: "~/b".to_string(), ratio_left: 1, ratio_right: 1, - explanation: "because".to_string() + explanation: "because".to_string(), + aspect: None, }] ); } @@ -1071,7 +1172,8 @@ mod tests { item2: "~/b".to_string(), ratio_left: 2, ratio_right: 1, - explanation: "because".to_string() + explanation: "because".to_string(), + aspect: None, }] ); } @@ -1100,27 +1202,201 @@ mod tests { item2: "~/whitepaper/b".to_string(), ratio_left: 3, ratio_right: 1, - explanation: "because".to_string() + explanation: "because".to_string(), + aspect: None, }] ); } #[test] - fn parse_rejects_leading_colon() { - let inputs = [":beauty", ":x", ":"]; + fn parse_aspect_declaration_and_prompt() { + let d1 = parse_full(":beauty").unwrap(); + assert_eq!( + d1.statements, + vec![Stmt::Aspect { + slug: Some("beauty".to_string()), + prompt: None, + }] + ); + + let d2 = parse_full(":beauty { winner is more beautiful }").unwrap(); + assert_eq!( + d2.statements, + vec![Stmt::Aspect { + slug: Some("beauty".to_string()), + prompt: Some("winner is more beautiful".to_string()), + }] + ); + + let d3 = parse_full(":beauty{no space}").unwrap(); + assert_eq!( + d3.statements, + vec![Stmt::Aspect { + slug: Some("beauty".to_string()), + prompt: Some("no space".to_string()), + }] + ); + + let d4 = parse_full(":a-b_c1").unwrap(); + assert_eq!( + d4.statements, + vec![Stmt::Aspect { + slug: Some("a-b_c1".to_string()), + prompt: None, + }] + ); + } + + #[test] + fn parse_aspect_prompt_preserves_fenced_braces() { + let input = ":beauty {\n```json\n{\"criterion\": true}\n```\n}"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Aspect { + slug: Some("beauty".to_string()), + prompt: Some("```json\n{\"criterion\": true}\n```".to_string()), + }] + ); + } + + #[test] + fn parse_bare_colon_resets_aspect() { + let doc = parse_full(":").unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Aspect { + slug: None, + prompt: None, + }] + ); + let doc = parse_full(": ").unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Aspect { + slug: None, + prompt: None, + }] + ); + } + + #[test] + fn parse_invalid_colon_lines_remain_prose() { + let inputs = [ + ":)", + ": note", + "::x", + ":UPPER", + ":has space {x}", + ":beauty extra", + ":Beauty", + ]; for input in &inputs { - let result = parse_full(input); - assert!(result.is_err(), "expected parse error for {input:?}"); - assert!( - result - .unwrap_err() - .to_string() - .contains("leading ':' is not supported"), - "wrong error for {input:?}" + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Prose { + text: input.to_string() + }], + "expected prose for {input:?}" ); } } + #[test] + fn parse_invalid_colon_lines_merge_with_adjacent_prose() { + let input = "hello\n:)\nworld"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![Stmt::Prose { + text: "hello\n:)\nworld".to_string() + }] + ); + } + + #[test] + fn parse_aspect_lexical_inheritance_across_votes_and_switches() { + let input = "\ +~/t/a {a}\n\ +~/t/b {b}\n\ +:beauty { more beautiful }\n\ +{pretty}\n\ +~/t/a 3:1 ~/t/b\n\ +{also pretty}\n\ +~/t/b 2:1 ~/t/a\n\ +:\n\ +{canonical}\n\ +~/t/a 1:1 ~/t/b\n\ +:speed\n\ +{faster}\n\ +~/t/a 4:1 ~/t/b\n"; + let doc = parse_full(input).unwrap(); + let votes: Vec<&Stmt> = doc + .statements + .iter() + .filter(|s| matches!(s, Stmt::Vote { .. })) + .collect(); + assert_eq!(votes.len(), 4); + assert!(matches!( + votes[0], + Stmt::Vote { + aspect: Some(a), + .. + } if a == "beauty" + )); + assert!(matches!( + votes[1], + Stmt::Vote { + aspect: Some(a), + .. + } if a == "beauty" + )); + assert!(matches!(votes[2], Stmt::Vote { aspect: None, .. })); + assert!(matches!( + votes[3], + Stmt::Vote { + aspect: Some(a), + .. + } if a == "speed" + )); + let aspects: Vec<_> = doc + .statements + .iter() + .filter_map(|s| match s { + Stmt::Aspect { slug, prompt } => Some((slug.as_deref(), prompt.is_some())), + _ => None, + }) + .collect(); + assert_eq!( + aspects, + vec![ + (Some("beauty"), true), + (None, false), + (Some("speed"), false), + ] + ); + } + + #[test] + fn parse_aspect_does_not_affect_item_definitions() { + let input = ":beauty\n~/t/x {defined after aspect}"; + let doc = parse_full(input).unwrap(); + assert_eq!( + doc.statements, + vec![ + Stmt::Aspect { + slug: Some("beauty".to_string()), + prompt: None, + }, + Stmt::Item { + title: "~/t/x".to_string(), + body: Some("defined after aspect".to_string()), + }, + ] + ); + } + #[test] fn parse_rejects_slash_prefixed_item_path() { let result = parse_full("/languages/python { use tilde }"); @@ -1206,6 +1482,7 @@ mod tests { ratio_left: 3, ratio_right: 1, explanation: "because".to_string(), + aspect: None, }] ); } diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs index 1835e850778b69da54ab0e85229cddd29056df82..542e8067021b1c4e602896af4a48cfe3b66ab020 100644 --- a/server/src/html/editor.rs +++ b/server/src/html/editor.rs @@ -9,7 +9,7 @@ use maud::{html, Markup}; use serde::Deserialize; use crate::{ - api::{resolve_item, validate_ingest_document}, + api::validate_ingest_document, html::JsBuilder, middleware::canonical_view_url, reducer::ScopeId, @@ -102,21 +102,12 @@ pub async fn editor_check( let mut simulated = { reduced_arc.read().await.clone() }; simulated.apply_event(event); - // Collect voted parent scopes. - let voted_parents: Vec = { - let mut parents = std::collections::HashSet::new(); - 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) = a.parent() { parents.insert(p); } - if let Some(p) = b.parent() { parents.insert(p); } - } - } - } - let mut out: Vec = parents.into_iter().collect(); - out.sort(); - out - }; + let rankings = crate::offline::rankings_for_document( + &simulated, + &ScopeId::Public, + "public", + &v.doc, + ); let status = html! { span class="editor-ok" { @@ -126,25 +117,27 @@ pub async fn editor_check( let results = html! { div id="editor-results" { - @for parent in &voted_parents { - @let scoped = crate::scope_rank::build_children_rankings(simulated.public(), parent); - @let label = format!("/{}", parent.tilde_tail().unwrap_or(parent.as_str())); - h3 { "ranking: " (label) } - @for comp in &scoped.component_rankings { + @for scope in &rankings { + @let heading = match &scope.aspect { + Some(slug) => format!("ranking: {} :{}", scope.parent, slug), + None => format!("ranking: {}", scope.parent), + }; + h3 { (heading) } + @for comp in &scope.components { ol class="editor-ranking" { - @for r in &comp.ranked { + @for r in &comp.ranking { li { - code { "~/" (r.item) } + code { (r.item.as_str()) } " " span class="muted" { (format!("{:.3}", r.score)) } } } } } - @if !scoped.unranked_items.is_empty() { + @if !scope.unranked_items.is_empty() { p class="muted" { "unranked: " - (scoped.unranked_items.iter().map(|i| format!("~/{i}")).collect::>().join(", ")) + (scope.unranked_items.iter().map(|i| i.as_str().to_string()).collect::>().join(", ")) } } } diff --git a/server/src/html/garden/item_page.rs b/server/src/html/garden/item_page.rs index 800ca79901480b9095c2b9ba133ca23035037c52..9be4460ff50746c5d54e62fcc37b88f746167cbf 100644 --- a/server/src/html/garden/item_page.rs +++ b/server/src/html/garden/item_page.rs @@ -3,8 +3,11 @@ use maud::html; use crate::{ html::forum::ThreadNav, path_types::ItemId, - reducer::ScopeId, - scope_rank::{build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive, ChildrenRankings}, + reducer::{ContentState, ScopeId}, + scope_rank::{ + build_children_rankings, build_children_rankings_in_group, build_rankings_for_item_set, + resolve_scope_recursive, ChildrenRankings, + }, }; use super::{ @@ -68,6 +71,13 @@ pub(super) struct RankHistoryEntryView { pub(super) caused_by: Vec, } +#[derive(Debug, Clone)] +pub(super) struct AspectRankingView { + pub(super) slug: String, + pub(super) prompt: Option, + pub(super) rankings: ChildrenRankings, +} + #[derive(Debug, Clone)] pub(super) struct ItemPageViewModel { pub(super) item: String, @@ -76,12 +86,46 @@ pub(super) struct ItemPageViewModel { /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply. pub(super) item_has_parent: bool, pub(super) child_rankings: ChildrenRankings, + pub(super) aspect_rankings: Vec, pub(super) child_depth: usize, pub(super) rank_history: Vec, /// Forum threads that mention or vote on this item. pub(super) threads: Vec, } +pub(super) fn aspect_rankings_for_parent( + content: &ContentState, + parent: &ItemId, +) -> Vec { + let parent = parent.clone().normalized_storage(); + let mut slugs: Vec = content + .aspect_groups + .keys() + .filter(|(p, _)| p == &parent) + .map(|(_, slug)| slug.clone()) + .collect(); + slugs.sort(); + slugs.dedup(); + slugs + .into_iter() + .filter_map(|slug| { + let group = content.aspect_group(&parent, &slug)?; + if group.voted_pairs.is_empty() { + return None; + } + let rankings = build_children_rankings_in_group(content, &parent, group); + if rankings.component_rankings.is_empty() { + return None; + } + Some(AspectRankingView { + prompt: content.aspect_prompt(&slug).map(str::to_string), + slug, + rankings, + }) + }) + .collect() +} + fn build_sibling_nav( reduced: &crate::reducer::ReducerState, scope: &ScopeId, @@ -218,6 +262,8 @@ fn build_rank_history( ratio_left, ratio_right, explanation, + aspect: None, + .. } = s { let a_str = crate::canonical_path::canonicalize_item(&item1); @@ -335,6 +381,7 @@ pub(super) fn build_item_page_view_model( sibling_nav, item_has_parent, child_rankings, + aspect_rankings: aspect_rankings_for_parent(content, &item_key), child_depth, rank_history, threads, diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs index eb542e9e8d72c927d4d1e27f76e10c0427a50c5c..d6ce27348f13714544191e4b4b048ad9027e9ab5 100644 --- a/server/src/html/garden/render.rs +++ b/server/src/html/garden/render.rs @@ -19,7 +19,7 @@ use crate::{ }, middleware::canonical_view_url, path_types::ItemId, - reducer::ScopeId, + reducer::{ContentState, ScopeId}, state::AppState, timeago, }; @@ -33,11 +33,80 @@ use super::{ child_depth_from_uri, garden_depth_select_markup, item_code_label, item_display_path, item_href, }, - item_page::{build_item_page_view_model, sibling_nav_markup}, + item_page::{build_item_page_view_model, sibling_nav_markup, AspectRankingView}, pin::{child_row_pin_or_vote, ont_pin_vote_controls, pinned_item_from_jar}, vote::vote_pool_href, }; +pub(super) fn ont_ranking_lists_markup( + rankings: &crate::scope_rank::ChildrenRankings, + nav: &ThreadNav, + pin_ref: Option<&(String, ItemId)>, + scope_content: &ContentState, + next_for_pin: &str, +) -> maud::Markup { + html! { + @for (ci, comp) in rankings.component_rankings.iter().enumerate() { + div class="ont-group-shell" { + div class="ont-group-meta" { + (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) + } + ol class="ont-ranking-list" { + @for r in comp.ranked.iter() { + @let item_url = item_href(r.item.as_str(), nav); + @let score_str = format!("{:.3}", r.score); + li data-garden-item=(r.item.as_str()) { + (child_row_pin_or_vote(nav, &r.item, pin_ref, scope_content, next_for_pin)) + a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } } + span class="ont-rank-score" { (score_str) } + } + } + } + } + } + @if !rankings.unranked_items.is_empty() { + div class="ont-group-shell ont-group-unsorted" { + div class="ont-group-meta" { "unranked" } + ul class="ont-group-list" { + @for name in &rankings.unranked_items { + li data-garden-item=(name.as_str()) { + (child_row_pin_or_vote(nav, name, pin_ref, scope_content, next_for_pin)) + @let href = item_href(name.as_str(), nav); + a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } } + } + } + } + } + } + } +} + +pub(super) fn aspect_ranking_sections_markup( + aspects: &[AspectRankingView], + nav: &ThreadNav, + pin_ref: Option<&(String, ItemId)>, + scope_content: &ContentState, + next_for_pin: &str, +) -> maud::Markup { + html! { + @for aspect in aspects { + section class="ont-tab-panel ont-tab-panel-aspect" { + h4 class="ont-aspect-heading" { ":" (aspect.slug) } + @if let Some(prompt) = &aspect.prompt { + p class="muted ont-aspect-prompt" { (prompt) } + } + (ont_ranking_lists_markup( + &aspect.rankings, + nav, + pin_ref, + scope_content, + next_for_pin, + )) + } + } + } +} + pub(super) async fn render_scope_view( state: AppState, browse: GardenBrowsePath, @@ -251,42 +320,22 @@ pub(super) async fn render_scope_view( } @if model.child_rankings.component_rankings.is_empty() { p class="muted" { "no voted pairs yet in this scope" } - } @else { - @for (ci, comp) in model.child_rankings.component_rankings.iter().enumerate() { - div class="ont-group-shell" { - div class="ont-group-meta" { - (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) - } - ol class="ont-ranking-list" { - @for r in comp.ranked.iter() { - @let item_url = item_href(r.item.as_str(), &nav); - @let score_str = format!("{:.3}", r.score); - li data-garden-item=(r.item.as_str()) { - (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin)) - a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } } - span class="ont-rank-score" { (score_str) } - } - } - } - } - } - } - - @if !model.child_rankings.unranked_items.is_empty() { - div class="ont-group-shell ont-group-unsorted" { - div class="ont-group-meta" { "unranked" } - ul class="ont-group-list" { - @for name in &model.child_rankings.unranked_items { - li data-garden-item=(name.as_str()) { - (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin)) - @let href = item_href(name.as_str(), &nav); - a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } } - } - } - } - } } + (ont_ranking_lists_markup( + &model.child_rankings, + &nav, + pin_ref.as_ref(), + scope_content, + &next_for_pin, + )) } + (aspect_ranking_sections_markup( + &model.aspect_rankings, + &nav, + pin_ref.as_ref(), + scope_content, + &next_for_pin, + )) @let cli = match &scope { ScopeId::Public => format!("npx slugsocial public garden body {cli_path_arg}"), ScopeId::Room(room_id) => format!("npx slugsocial private {room_id} garden body {cli_path_arg}"), diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs index b6fd4f8d573b45493bf6596ee833eec1b3917476..4bd0bba63a8a199368edfa8fc245d81bb23165cb 100644 --- a/server/src/html/garden/routes.rs +++ b/server/src/html/garden/routes.rs @@ -31,6 +31,7 @@ use super::{ browse::{GardenBrowsePath, scoped_bc_path_external}, copy::garden_rank_copy_button_markup, item::{child_depth_from_uri, garden_depth_select_markup, item_display_path, item_href}, + item_page::aspect_rankings_for_parent, render::render_scope_view, }; @@ -41,17 +42,19 @@ pub async fn garden_index( ) -> impl IntoResponse { let nav = ThreadNav::public(); let child_depth = child_depth_from_uri(&uri); - let child_rankings = { + let (child_rankings, aspect_rankings) = { let reduced = state.reduced.read().await; let content = reduced.public(); let root = ItemId::ontology_root(); - if child_depth > 1 { + let child_rankings = if child_depth > 1 { let items = resolve_scope_recursive(content, &[root.as_str().to_string()], child_depth); build_rankings_for_item_set(content, &items) } else { build_children_rankings(content, &root) - } + }; + let aspect_rankings = aspect_rankings_for_parent(content, &root); + (child_rankings, aspect_rankings) }; let url_key = canonical_view_url(&uri); @@ -104,6 +107,29 @@ pub async fn garden_index( } } } + @for aspect in &aspect_rankings { + section class="ont-tab-panel ont-tab-panel-aspect" { + h4 class="ont-aspect-heading" { ":" (aspect.slug) } + @if let Some(prompt) = &aspect.prompt { + p class="muted ont-aspect-prompt" { (prompt) } + } + @for (ci, comp) in aspect.rankings.component_rankings.iter().enumerate() { + div class="ont-group-shell" { + div class="ont-group-meta" { + (format!("ordering {} items={} pairs={}", ci + 1, comp.ranked.len(), comp.pairs)) + } + ol class="ont-ranking-list" { + @for r in comp.ranked.iter() { + @let href = item_href(r.item.as_str(), &nav); + li { + a href=(href) { (item_display_path(r.item.as_str())) } + } + } + } + } + } + } + } (cli_panel(&["npx slugsocial garden tree"])) }, Some(view_count), diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs index f4e733ad679cffbc06d768998b5a80c00a0d3e06..6fd72f0319c76b5acb6bfbadde3792d279c63fc1 100644 --- a/server/src/html/garden/tests.rs +++ b/server/src/html/garden/tests.rs @@ -2,6 +2,7 @@ use super::{ access::content_for_garden_view, external::{external_frame_allowed, external_resolver_status_markup, external_source_href}, item_page::{build_item_page_view_model, sibling_nav_markup}, + render::aspect_ranking_sections_markup, pin::ont_pin_vote_controls, vote::{ canonical_edge_items, edge_vote_count_for_pair, edge_vote_entries_for_pair, @@ -281,6 +282,76 @@ fn sibling_nav_splits_each_unranked_into_its_own_group() { assert_eq!(nav.groups[2].links.len(), 1); } +#[test] +fn item_page_renders_aspect_section_below_canonical() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "~/topic {root}\n\ + ~/topic/a {alpha}\n\ + ~/topic/b {beta}\n\ + {canonical}\n\ + ~/topic/a 3:1 ~/topic/b\n\ + :beauty { winner is more beautiful }\n\ + {pretty}\n\ + ~/topic/b 2:1 ~/topic/a\n", + ); + + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic", 1); + assert_eq!(model.child_rankings.component_rankings.len(), 1); + assert_eq!(model.aspect_rankings.len(), 1); + assert_eq!(model.aspect_rankings[0].slug, "beauty"); + assert_eq!( + model.aspect_rankings[0].prompt.as_deref(), + Some("winner is more beautiful") + ); + assert_eq!(model.aspect_rankings[0].rankings.component_rankings.len(), 1); + let canon_top = model.child_rankings.component_rankings[0].ranked[0] + .item + .as_str(); + let aspect_top = model.aspect_rankings[0].rankings.component_rankings[0].ranked[0] + .item + .as_str(); + assert!(canon_top.contains("topic/a")); + assert!(aspect_top.contains("topic/b")); + + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let html = aspect_ranking_sections_markup( + &model.aspect_rankings, + &ThreadNav::public(), + None, + content, + "/~/topic", + ) + .into_string(); + assert!( + html.contains("ont-tab-panel-aspect"), + "missing aspect section: {html}" + ); + assert!(html.contains(":beauty"), "missing aspect heading: {html}"); + assert!( + html.contains("winner is more beautiful"), + "missing aspect prompt: {html}" + ); + assert!( + html.contains("ont-ranking-list"), + "missing ranking list: {html}" + ); +} + +#[test] +fn item_page_omits_aspect_section_without_aspect_votes() { + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "~/topic {root}\n~/topic/a {alpha}\n~/topic/b {beta}\n{canonical}\n~/topic/a 3:1 ~/topic/b\n", + ); + let model = build_item_page_view_model(&reduced, &ScopeId::Public, "~/topic", 1); + assert!(model.aspect_rankings.is_empty()); +} + #[test] fn item_page_model_builds_ranked_child_components() { let mut reduced = ReducerState::default(); diff --git a/server/src/mcp/mod.rs b/server/src/mcp/mod.rs index 778c805a944bd4266a5b7742031f3aa5aad3c48a..8d67814a82eda9568a228a61c3f17187b600f7d2 100644 --- a/server/src/mcp/mod.rs +++ b/server/src/mcp/mod.rs @@ -393,7 +393,8 @@ fn tools_list() -> Value { "depth": {"type": "integer"}, "offset": {"type": "integer"}, "limit": {"type": "integer"}, - "percent": {"type": "boolean"} + "percent": {"type": "boolean"}, + "aspect": {"type": "string", "description": "Optional aspect slug; omit for the canonical ranking"} } }), json!({"type": "object"}), @@ -880,6 +881,7 @@ async fn tools_call(state: &AppState, headers: &HeaderMap, params: &Value) -> Va offset: arg_usize(&args, "offset"), limit: arg_usize(&args, "limit"), percent: arg_bool(&args, "percent"), + aspect: arg_string(&args, "aspect"), }, ) .await diff --git a/server/src/offline.rs b/server/src/offline.rs index 93d4d5ee55449da644f732bc0ba007ee4c2c7079..efce128e6fd37e016a7dbef346dafbe4c99a0df9 100644 --- a/server/src/offline.rs +++ b/server/src/offline.rs @@ -106,6 +106,7 @@ fn document_stats(doc: &dsl::Document) -> CompileStats { dsl::Stmt::Item { .. } => items += 1, dsl::Stmt::Vote { .. } => votes += 1, dsl::Stmt::Prose { .. } => prose_blocks += 1, + dsl::Stmt::Aspect { .. } => {} } } CompileStats { @@ -138,38 +139,61 @@ fn threads_in_document(text: &str) -> Vec { tags } -fn voted_parent_scopes(doc: &dsl::Document) -> Vec { - let mut parents = HashSet::new(); +fn voted_ranking_keys(doc: &dsl::Document) -> Vec<(ItemId, Option)> { + let mut keys = HashSet::new(); for stmt in &doc.statements { - if let dsl::Stmt::Vote { item1, item2, .. } = stmt { + if let dsl::Stmt::Vote { + item1, + item2, + aspect, + .. + } = stmt + { if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) { if let Some(p) = a.parent() { - parents.insert(p); + keys.insert((p, aspect.clone())); } if let Some(p) = b.parent() { - parents.insert(p); + keys.insert((p, aspect.clone())); } } } } - let mut out: Vec = parents.into_iter().collect(); + let mut out: Vec<(ItemId, Option)> = keys.into_iter().collect(); out.sort(); out } -fn rankings_for_simulated( +pub fn rankings_for_document( simulated: &ReducerState, scope: &ScopeId, room_wire: &str, doc: &dsl::Document, ) -> Vec { - voted_parent_scopes(doc) - .iter() - .map(|parent| { + use crate::scope_rank::build_rankings_for_group_and_items; + + voted_ranking_keys(doc) + .into_iter() + .map(|(parent, aspect)| { let scoped_content = simulated .content_for_scope(scope) .unwrap_or_else(|| simulated.public()); - let scoped = build_children_rankings(scoped_content, parent); + let empty = crate::reducer::GroupState::new(); + let scoped = if let Some(ref slug) = aspect { + let group = scoped_content + .aspect_group(&parent, slug) + .unwrap_or(&empty); + build_rankings_for_group_and_items( + group, + &scoped_content + .item_children + .get(&parent) + .map(|s| s.iter().cloned().collect::>()) + .unwrap_or_default(), + ) + } else { + build_children_rankings(scoped_content, &parent) + }; let components: Vec = scoped .component_rankings .into_iter() @@ -187,7 +211,8 @@ fn rankings_for_simulated( }) .collect(); CheckScopeRanking { - parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(), + parent: GardenItemUrl::from_stored(&parent, room_wire).into_inner(), + aspect, components, unranked_items: scoped .unranked_items @@ -234,7 +259,7 @@ fn compile_document_inner( Ok(CompileResult { ok: true, threads: threads_in_document(text), - rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc), + rankings: rankings_for_document(&simulated, &scope, room_key, &validated.doc), stats: document_stats(&validated.doc), ingest_id, ingest_line, @@ -390,6 +415,46 @@ mod tests { assert!(err.error.contains("undefined")); } + #[test] + fn compile_canonical_plus_two_aspects_emits_three_ranking_groups() { + let text = "\ +~/songs/a { a }\n\ +~/songs/b { b }\n\ +{ canonical }\n\ +~/songs/a 3:1 ~/songs/b\n\ +:beauty { more beautiful }\n\ +{ pretty }\n\ +~/songs/a 2:1 ~/songs/b\n\ +:speed { faster }\n\ +{ zippy }\n\ +~/songs/a 4:1 ~/songs/b\n"; + let result = compile_document(&ReducerState::default(), "public", text).unwrap(); + assert!(result.ok); + assert_eq!(result.rankings.len(), 3); + let aspects: Vec> = result + .rankings + .iter() + .map(|r| r.aspect.as_deref()) + .collect(); + assert_eq!(aspects, vec![None, Some("beauty"), Some("speed")]); + for ranking in &result.rankings { + assert_eq!(ranking.components.len(), 1); + assert_eq!(ranking.components[0].ranking.len(), 2); + } + assert!(result.rankings[0].components[0].ranking[0] + .item + .as_str() + .contains("songs/a")); + assert!(result.rankings[1].components[0].ranking[0] + .item + .as_str() + .contains("songs/a")); + assert!(result.rankings[2].components[0].ranking[0] + .item + .as_str() + .contains("songs/a")); + } + #[test] fn scan_empty_jsonl_is_ok() { let dir = tempfile::tempdir().unwrap(); diff --git a/server/src/reducer.rs b/server/src/reducer.rs index edb485df57a89d43858cc152959301a9b8df3a75..bba0980224a837130becd553b0d661985a83d49c 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -237,6 +237,11 @@ pub struct ContentState { pub item_threads: HashMap>, /// Per-item rank history, oldest first. pub rank_history: HashMap>, + /// Aspect ranking groups keyed by (parent item, aspect slug). + /// Canonical votes stay in `ranking_group`. + pub aspect_groups: HashMap<(ItemId, String), GroupState>, + /// Prompt text per aspect slug in this room; last non-empty write wins. + pub aspect_prompts: HashMap, /// RAM-only memo of rank positions at `ranking_group.generation`. /// /// The next vote ingest's "before" positions are exactly the previous vote @@ -248,6 +253,16 @@ pub struct ContentState { pub(crate) rank_position_cache: Option, } +impl ContentState { + pub fn aspect_group(&self, parent: &ItemId, aspect: &str) -> Option<&GroupState> { + self.aspect_groups.get(&(parent.clone(), aspect.to_string())) + } + + pub fn aspect_prompt(&self, aspect: &str) -> Option<&str> { + self.aspect_prompts.get(aspect).map(String::as_str) + } +} + #[derive(Debug, Clone)] pub struct ReducerState { pub content: HashMap, @@ -604,7 +619,13 @@ impl ReducerState { .statements .iter() .filter_map(|s| { - if let dsl::Stmt::Vote { item1, item2, .. } = s { + if let dsl::Stmt::Vote { + item1, + item2, + aspect: None, + .. + } = s + { Some([item1, item2]) } else { None @@ -643,12 +664,20 @@ impl ReducerState { } } } + dsl::Stmt::Aspect { slug, prompt } => { + if let (Some(s), Some(p)) = (slug, prompt) { + if !p.trim().is_empty() { + content.aspect_prompts.insert(s, p); + } + } + } dsl::Stmt::Vote { item1, item2, ratio_left, ratio_right, explanation, + aspect, } => { let Some(item_a) = Self::normalize_item(&item1) else { continue; @@ -677,10 +706,22 @@ impl ReducerState { Self::add_child_edge(content, &item_a); Self::add_child_edge(content, &item_b); - content.ranking_group.apply_vote(vote.clone()); + if let Some(asp) = aspect { + if let (Some(pa), Some(pb)) = (item_a.parent(), item_b.parent()) { + if pa == pb { + content + .aspect_groups + .entry((pa, asp)) + .or_default() + .apply_vote(vote); + } + } + } else { + content.ranking_group.apply_vote(vote.clone()); - for it in [&item_a, &item_b] { - nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone())); + for it in [&item_a, &item_b] { + nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone())); + } } } dsl::Stmt::Prose { .. } => {} @@ -1065,3 +1106,180 @@ mod rank_position_cache_tests { assert_eq!(content.rank_history[&ItemId::parse("~/memo/b").unwrap()].len(), 2); } } + +#[cfg(test)] +mod aspect_tests { + use super::*; + use crate::events::PostRedacted; + use crate::ranking::ranked_items; + + fn ingest(id: &str, raw: &str) -> Ingest { + Ingest { + ts: 1, + id: id.to_string(), + raw: raw.to_string(), + principal: "tester".to_string(), + delegate: None, + room_id: "public".to_string(), + thread_tag: "aspects".to_string(), + } + } + + fn parent_songs() -> ItemId { + ItemId::parse("~/songs").unwrap() + } + + const SETUP: &str = "\ +~/songs/a { a }\n\ +~/songs/b { b }\n\ +{ canonical }\n\ +~/songs/a 3:1 ~/songs/b\n\ +:beauty { more beautiful }\n\ +{ pretty }\n\ +~/songs/a 2:1 ~/songs/b\n\ +:speed { faster }\n\ +{ zippy }\n\ +~/songs/a 4:1 ~/songs/b\n"; + + #[test] + fn aspect_votes_create_separate_groups_and_leave_canonical_unchanged() { + let mut state = ReducerState::default(); + state.apply_event(Event::Ingest(ingest("setup", SETUP))); + + let content = state.public(); + assert_eq!(content.ranking_group.voted_pairs.len(), 1); + assert_eq!(content.ranking_group.recent_votes.len(), 1); + assert_eq!(content.ranking_group.recent_votes[0].ratio_left, 3); + assert_eq!(content.ranking_group.recent_votes[0].ratio_right, 1); + + let beauty = content + .aspect_group(&parent_songs(), "beauty") + .expect("beauty group"); + assert_eq!(beauty.voted_pairs.len(), 1); + assert_eq!(beauty.recent_votes[0].ratio_left, 2); + assert_eq!(beauty.recent_votes[0].ratio_right, 1); + + let speed = content + .aspect_group(&parent_songs(), "speed") + .expect("speed group"); + assert_eq!(speed.voted_pairs.len(), 1); + assert_eq!(speed.recent_votes[0].ratio_left, 4); + assert_eq!(speed.recent_votes[0].ratio_right, 1); + + assert_eq!( + content.aspect_prompt("beauty"), + Some("more beautiful") + ); + assert_eq!(content.aspect_prompt("speed"), Some("faster")); + assert!(content.item_votes.values().all(|votes| { + votes.iter().all(|v| v.ratio_left == 3 && v.ratio_right == 1) + })); + } + + #[test] + fn aspect_prompt_overwrite_matches_item_body_last_write() { + let mut state = ReducerState::default(); + state.apply_event(Event::Ingest(ingest( + "first", + ":beauty { first prompt }\n~/songs/a { a }", + ))); + state.apply_event(Event::Ingest(ingest( + "second", + ":beauty { second prompt }", + ))); + assert_eq!(state.public().aspect_prompt("beauty"), Some("second prompt")); + state.apply_event(Event::Ingest(ingest("empty", ":beauty { }"))); + assert_eq!(state.public().aspect_prompt("beauty"), Some("second prompt")); + } + + #[test] + fn redacting_post_with_aspect_votes_removes_them() { + let mut state = ReducerState::default(); + state.apply_event(Event::Ingest(ingest( + "items", + "~/songs/a { a }\n~/songs/b { b }\n{ canonical }\n~/songs/a 3:1 ~/songs/b", + ))); + state.apply_event(Event::Ingest(ingest( + "aspects", + ":beauty\n{ pretty }\n~/songs/a 2:1 ~/songs/b", + ))); + assert!(state.public().aspect_group(&parent_songs(), "beauty").is_some()); + assert_eq!(state.public().ranking_group.voted_pairs.len(), 1); + + state.apply_event(Event::PostRedacted(PostRedacted { + ts: 2, + post_id: "aspects".to_string(), + principal: "tester".to_string(), + })); + + let content = state.public(); + assert!( + content.aspect_group(&parent_songs(), "beauty").is_none(), + "redacted aspect votes must leave the group" + ); + assert!(content.aspect_prompt("beauty").is_none()); + assert_eq!(content.ranking_group.voted_pairs.len(), 1); + assert_eq!(content.ranking_group.recent_votes[0].ratio_left, 3); + } + + #[test] + fn replay_determinism_same_events_same_rankings() { + let events = [ + Event::Ingest(ingest("setup", SETUP)), + Event::Ingest(ingest( + "more", + ":\n{ more canonical }\n~/songs/b 2:1 ~/songs/a", + )), + ]; + let mut a = ReducerState::default(); + let mut b = ReducerState::default(); + for ev in &events { + a.apply_event(ev.clone()); + b.apply_event(ev.clone()); + } + + let ca = a.public(); + let cb = b.public(); + assert_eq!(ca.ranking_group.idx_to_item, cb.ranking_group.idx_to_item); + assert_eq!(ca.ranking_group.voted_pairs, cb.ranking_group.voted_pairs); + assert_eq!(ca.aspect_groups.len(), cb.aspect_groups.len()); + for (key, ga) in &ca.aspect_groups { + let gb = cb.aspect_groups.get(key).expect("matching aspect group"); + assert_eq!(ga.idx_to_item, gb.idx_to_item); + assert_eq!(ga.voted_pairs, gb.voted_pairs); + let mut ra = ga.clone(); + let mut rb = gb.clone(); + let la = ranked_items(&mut ra, 20000, 1e-9); + let lb = ranked_items(&mut rb, 20000, 1e-9); + assert_eq!( + la.iter().map(|r| r.item.as_str()).collect::>(), + lb.iter().map(|r| r.item.as_str()).collect::>() + ); + } + assert_eq!(ca.aspect_prompts, cb.aspect_prompts); + } + + #[test] + fn thread_graduation_reparse_inherits_aspects() { + let raw = SETUP; + let mut private = ingest("priv", raw); + private.room_id = "aa11bb/studio".to_string(); + let mut public = ingest("pub", raw); + public.room_id = "public".to_string(); + + let mut state = ReducerState::default(); + state.apply_event(Event::Ingest(private)); + state.apply_event(Event::Ingest(public)); + + let priv_content = state + .content_for_scope(&ScopeId::Room("aa11bb/studio".into())) + .expect("private scope"); + let pub_content = state.public(); + assert!(priv_content.aspect_group(&parent_songs(), "beauty").is_some()); + assert!(pub_content.aspect_group(&parent_songs(), "beauty").is_some()); + assert_eq!( + priv_content.aspect_prompt("beauty"), + pub_content.aspect_prompt("beauty") + ); + } +} diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs index 84fcdb7431199624e71be86e593386bd9351586c..b5d7fd08a6aafb39b0c4b3f9b75e3b3402b7c38e 100644 --- a/server/src/scope_rank.rs +++ b/server/src/scope_rank.rs @@ -107,7 +107,13 @@ pub fn build_rankings_for_item_set( content: &ContentState, items_in_scope: &[ItemId], ) -> ChildrenRankings { - let group = &content.ranking_group; + build_rankings_for_group_and_items(&content.ranking_group, items_in_scope) +} + +pub fn build_rankings_for_group_and_items( + group: &GroupState, + items_in_scope: &[ItemId], +) -> ChildrenRankings { let mut items_in_scope: Vec = items_in_scope.to_vec(); items_in_scope.sort(); @@ -201,6 +207,20 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child build_rankings_for_item_set(content, &items) } +pub fn build_children_rankings_in_group( + content: &ContentState, + parent: &ItemId, + group: &GroupState, +) -> ChildrenRankings { + let parent = parent.clone().normalized_storage(); + let items: Vec = content + .item_children + .get(&parent) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + build_rankings_for_group_and_items(group, &items) +} + /// Host-only `https://…` roots for the external garden index (`/-/`). /// /// Includes every `https://host` ancestor of any [`ItemId::Web`] item that appears in @@ -314,15 +334,8 @@ mod tests { item_children.insert(parent, set); } ContentState { - ranking_group: crate::reducer::GroupState::new(), - items: HashSet::new(), - item_bodies: HashMap::new(), item_children, - item_votes: HashMap::new(), - item_snippets: HashMap::new(), - item_threads: HashMap::new(), - rank_history: HashMap::new(), - rank_position_cache: None, + ..Default::default() } } @@ -408,15 +421,9 @@ mod tests { let mut items = HashSet::new(); items.insert(repo.clone()); let content = ContentState { - ranking_group: crate::reducer::GroupState::new(), items, - item_bodies: HashMap::new(), item_children, - item_votes: HashMap::new(), - item_snippets: HashMap::new(), - item_threads: HashMap::new(), - rank_history: HashMap::new(), - rank_position_cache: None, + ..Default::default() }; let roots = external_root_host_items(&content); assert_eq!(roots, vec![gh]); diff --git a/server/static/theme_default.css b/server/static/theme_default.css index 23ccc6a5976622b057b64ceb833aa6de71b42a82..3fbb84af0e217e32b17edd496761ace3d0357286 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -100,6 +100,15 @@ h1, h2, h3 { margin: 14px 0 6px; text-transform: uppercase; } +h4.ont-aspect-heading { + color: var(--meta); + font-size: 11px; + font-weight: bold; + letter-spacing: 0.12em; + margin: 18px 0 4px; + text-transform: uppercase; +} +p.ont-aspect-prompt { margin: 0 0 8px; } /* ---------------------------------------------------------------- LINKS (default) diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 4287eeb1816cb30e5bd399d6ed059e1a68bb0d24..2c44940d9ac424acb437f335da1fa0eefb3021a3 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -45,6 +45,15 @@ h1, h2, h3 { letter-spacing: 0.14em; margin: 1.5rem 0 0.5rem; } +h4.ont-aspect-heading { + color: var(--ink-dim); + font-family: var(--font-ui); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.14em; + margin: 1.75rem 0 0.35rem; +} +p.ont-aspect-prompt { margin: 0 0 0.5rem; } a { color: var(--accent); diff --git a/server/tests/dsl_fixtures.rs b/server/tests/dsl_fixtures.rs index 62f3fa14adc8203562338bf75f9310b99bacbb43..48c0b3e8d53e4fd12a1fe2a57d83c4884d587842 100644 --- a/server/tests/dsl_fixtures.rs +++ b/server/tests/dsl_fixtures.rs @@ -18,6 +18,7 @@ fn parses_tutorial_fixture_with_prose() { dsl::Stmt::Item { .. } => items += 1, dsl::Stmt::Vote { .. } => votes += 1, dsl::Stmt::Prose { .. } => prose += 1, + dsl::Stmt::Aspect { .. } => {} } } @@ -64,6 +65,7 @@ fn parses_external_dash_vote_line() { ratio_left: 2, ratio_right: 1, explanation: "reason".to_string(), + aspect: None, }] ); } diff --git a/types/src/lib.rs b/types/src/lib.rs index a4b79422f0e87f03761e8815b1c734db2e2685d7..31d4cf0c656c72a89199708d7a70b9c990e33756 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -70,6 +70,8 @@ pub struct RankComponent { pub struct RankResponse { pub components: Vec, pub unranked_items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aspect: Option, } /// Graph connectivity stats for a scope, returned with pair suggestions. @@ -392,6 +394,8 @@ pub enum RpcCommand { limit: Option, #[serde(default)] percent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + aspect: Option, }, GetGardenItem { room: String, @@ -660,6 +664,8 @@ pub struct IngestResponse { pub struct CheckScopeRanking { /// Parent scope path (e.g. "/models" or "/" for root). pub parent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aspect: Option, pub components: Vec, pub unranked_items: Vec, }