You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [0366806e] Color rank rows by vote mass within each group, not list position. Min–max normalization keeps similar scores visually close while still using the full gradient as groups grow and absolute mass dilutes. Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index a58cbbee3490a08a625cb06df06848c59a615d65..4eff2e19ed4d303ff8e80c1eabd8a15b4990e643 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -264,12 +264,19 @@ pub fn scope_theme_style(parent: &ItemId) -> String { ) } -fn rank_row_style(parent: &ItemId, ordinal: usize, total: usize) -> String { - let t = if total <= 1 { - 0.0 - } else { - ordinal as f64 / (total - 1) as f64 - }; +/// Map vote mass to gradient position using the group's score range, not raw mass or +/// list position. Vote mass sums to 1 across the component, so absolute values dilute +/// as N grows; min–max within the visible list preserves similar scores → similar colors. +fn score_gradient_t(score: f64, min_score: f64, max_score: f64) -> f64 { + let spread = max_score - min_score; + if spread < 1e-9 { + return 0.5; + } + ((max_score - score) / spread).clamp(0.0, 1.0) +} + +fn rank_row_style(parent: &ItemId, score: f64, min_score: f64, max_score: f64) -> String { + let t = score_gradient_t(score, min_score, max_score); let base_hue = scope_base_hue(parent); let hue = (base_hue + 118.0 * t) % 360.0; let lightness = 0.74 - 0.34 * t; @@ -295,14 +302,15 @@ fn rank_list( highlighted: &HashSet, tree: &GlobalTree, ) -> Markup { - let group_len = items.len(); + let min_score = items.iter().map(|r| r.score).fold(f64::INFINITY, f64::min); + let max_score = items.iter().map(|r| r.score).fold(f64::NEG_INFINITY, f64::max); html! { @if !items.is_empty() { h3 class="rank-heading muted small" { (label) } ol class="rank-list" { @for (i, r) in items.iter().enumerate() { @let href = item_href(&r.item); - @let style = rank_row_style(parent, i, group_len); + @let style = rank_row_style(parent, r.score, min_score, max_score); @let class = rank_row_class(&r.item, highlighted); li class=(class) data-rank-item=(r.item.as_str()) @@ -517,19 +525,37 @@ pub async fn browse(State(state): State, uri: Uri) -> impl IntoRespons #[cfg(test)] mod tests { - use super::{rank_row_style, SORTER_UI_JS}; + use super::{rank_row_style, score_gradient_t, SORTER_UI_JS}; use crate::path_types::ItemId; #[test] - fn rank_row_style_gradients_per_group_not_globally() { + fn score_gradient_t_uses_group_range_not_absolute_mass() { + assert!((score_gradient_t(0.12, 0.08, 0.12) - 0.0).abs() < 1e-9); + assert!((score_gradient_t(0.08, 0.08, 0.12) - 1.0).abs() < 1e-9); + // Raw 12% mass would map near the dark end globally; within this group it's the top. + assert!(score_gradient_t(0.12, 0.08, 0.12) < score_gradient_t(0.12, 0.0, 1.0)); + } + + #[test] + fn score_gradient_t_similar_scores_similar_t() { + let a = score_gradient_t(0.41, 0.20, 0.60); + let b = score_gradient_t(0.40, 0.20, 0.60); + assert!((a - b).abs() < 0.05); + assert!((a - score_gradient_t(0.60, 0.20, 0.60)).abs() > 0.3); + } + + #[test] + fn score_gradient_t_tied_scores_neutral() { + assert!((score_gradient_t(0.25, 0.25, 0.25) - 0.5).abs() < 1e-9); + } + + #[test] + fn rank_row_style_same_inputs_same_color() { let parent = ItemId::opaque("test-scope"); - let first_in_four = rank_row_style(&parent, 0, 4); - let last_in_four = rank_row_style(&parent, 3, 4); - let first_in_two = rank_row_style(&parent, 0, 2); - let last_in_two = rank_row_style(&parent, 1, 2); - assert_eq!(first_in_four, first_in_two); - assert_eq!(last_in_four, last_in_two); - assert_ne!(first_in_four, last_in_four); + assert_eq!( + rank_row_style(&parent, 0.33, 0.20, 0.60), + rank_row_style(&parent, 0.33, 0.20, 0.60), + ); } #[test] Side B — contributor: tommy-mor Side B — commit message: [1d9d8ade] init seed Side B — unified diff (full patch): diff --git a/TEST.sh b/TEST.sh new file mode 100755 index 0000000000000000000000000000000000000000..266b71f29259f5c2cad2617476ebe2ebc9596d39 --- /dev/null +++ b/TEST.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cargo test --all +./scripts/clj-test.sh diff --git a/bundle.js b/bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265 --- /dev/null +++ b/bundle.js @@ -0,0 +1,52 @@ +/** + * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here. + */ +(function () { + function evalJs(js) { + if (js && String(js).trim()) { + eval(js); + } + } + + // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie) + + function initSlugUi() { + // POST forms → eval response (except theme + full-navigation forms) + document.addEventListener('submit', async function (e) { + var f = e.target; + if (!f || f.tagName !== 'FORM') return; + if ((f.method || 'get').toLowerCase() !== 'post') return; + if (f.id === 'slug-theme-form') return; + if (f.getAttribute('data-navigate') === 'full') return; + e.preventDefault(); + var resp = await fetch(f.action, { + method: 'POST', + body: new URLSearchParams(new FormData(f)), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + credentials: 'same-origin', + }); + evalJs(await resp.text()); + }); + + // SSE: server-pushed JS + function connectSSE() { + var ssePath = window.location.pathname + window.location.search; + var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath)); + es.onmessage = function (e) { + evalJs(e.data); + }; + es.onerror = function () { + es.close(); + setTimeout(connectSSE, 3000); + }; + } + connectSSE(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSlugUi); + } else { + initSlugUi(); + } +})(); + diff --git a/clj-test.sh b/clj-test.sh new file mode 100755 index 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952 --- /dev/null +++ b/clj-test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p target +exec clojure -M:kaocha diff --git a/forms.rs b/forms.rs new file mode 100644 index 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a --- /dev/null +++ b/forms.rs @@ -0,0 +1,145 @@ +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs +//! Plan2-style JSON templates with `{"$form": "field_name"}` holes, filled from +//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before** +//! deserializing into a typed struct. +//! +//! # Wire format +//! +//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty +//! printing, strings escaped per JSON rules (`\"`, `\n`, etc.). Embed that string +//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not +//! bespoke encodings. +//! +//! # Power vs flat hidden fields +//! +//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`), +//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects, +//! arrays, and optional fields without inventing a new naming scheme each time. +//! +//! # Security +//! +//! Substitution runs **before** `serde` into your command type. It does not fix +//! authorization: if the client can replace the hidden `__rpc__` value, they can +//! change the command shape unless you validate (signed blob, server-side session +//! context, or treat the blob as hints only). Same threat model as any hidden field. + +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; + +/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field. +pub fn template_json_compact(v: &T) -> serde_json::Result { + serde_json::to_string(v) +} + +/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted +/// string for `key` (empty if missing). Other keys are unchanged. +pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) { + match val { + Value::Object(map) => { + if map.len() == 1 { + if let Some(Value::String(field_name)) = map.get("$form") { + let submitted = form_data + .get(field_name.as_str()) + .map(|s| s.as_str()) + .unwrap_or(""); + *val = Value::String(submitted.to_string()); + return; + } + } + for v in map.values_mut() { + substitute_form_vars(v, form_data); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + substitute_form_vars(v, form_data); + } + } + _ => {} + } +} + +/// Parse JSON, apply [`substitute_form_vars`], return the mutated value. +pub fn fill_template_from_form( + template_json: &str, + form_data: &HashMap, +) -> Result { + let mut v: Value = serde_json::from_str(template_json)?; + substitute_form_vars(&mut v, form_data); + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Demo { + room: String, + thread_tag: String, + nested: Nested, + } + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Nested { + text: String, + } + + #[test] + fn holes_become_strings() { + let json = r#"{ + "room": "public", + "thread_tag": {"$form": "tag"}, + "nested": {"text": {"$form": "body"}} + }"#; + let mut form = HashMap::new(); + form.insert("tag".into(), "foo".into()); + form.insert("body".into(), "hello\nworld".into()); + + let v = fill_template_from_form(json, &form).unwrap(); + let d: Demo = serde_json::from_value(v).unwrap(); + assert_eq!( + d, + Demo { + room: "public".into(), + thread_tag: "foo".into(), + nested: Nested { + text: "hello\nworld".into(), + }, + } + ); + } + + #[test] + fn missing_form_key_is_empty_string() { + let json = r#"{"x": {"$form": "nope"}}"#; + let mut form = HashMap::new(); + form.insert("other".into(), "y".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["x"], ""); + } + + #[test] + fn array_of_holes() { + let json = r#"{"items": [{"$form": "a"}, {"$form": "b"}]}"#; + let mut form = HashMap::new(); + form.insert("a".into(), "1".into()); + form.insert("b".into(), "2".into()); + let v = fill_template_from_form(json, &form).unwrap(); + assert_eq!(v["items"], serde_json::json!(["1", "2"])); + } + + #[test] + fn template_json_compact_escapes_and_single_line() { + let s = template_json_compact(&serde_json::json!({ + "x": "quote\"and\nnewline" + })) + .unwrap(); + assert!(!s.contains('\n')); + assert!(s.contains("\\\"") || s.contains("\\n")); + } +} +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ + diff --git a/gameifying.tdsl b/gameifying.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15 --- /dev/null +++ b/gameifying.tdsl @@ -0,0 +1,2 @@ +consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. +or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive. diff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a --- /dev/null +++ b/pagerank_streaming.tdsl @@ -0,0 +1,11 @@ +eventually i want to have ranking histories. +like "visionary" and "fraud" waxing and waning in a uplot graph over time for #elon-musk. +there are too many query combinations to precomupte the ranking histories +(arbitrary user filters, and tag overlap combinations maybe), +so we're just going to have to calculate them all on demand. +computers are fast, its okay. for each vote, we need to calculate rank centrality again. +i was thinking, for n votes we calculate the rank centrality, +and get node weights. we then output that data to the client (over websocket, or testable barrier). +then we calculate n+1 votes, _but we keep the node weights in memory_ +so the rank centrality process converges faster. +this also has the side effect of making the ranking stream in satisfyingly as you load the page. diff --git a/parser.rs b/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349 --- /dev/null +++ b/parser.rs @@ -0,0 +1,1808 @@ +use std::collections::HashMap; +use std::rc::Rc; +use std::cell::RefCell; +use crate::ui::action::UIAction; +use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion}; + +// --- Core Abstractions --- + +/// Unique identifier for nodes in the graph +type NodeId = &'static str; + +/// Pattern matching for edges +#[derive(Debug, Clone)] +pub enum EdgePattern { + /// Matches exact literal string + Literal(&'static str), + + /// Matches any prefix of a string and suggests the full string + /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com" + PrefixOf(&'static str), + + /// Captures a variable segment (e.g., subreddit name, username) + Variable(&'static str), + + /// Matches any string (wildcard) + Any, +} + +impl EdgePattern { + /// Try to match this pattern against input, return (consumed_chars, captured_value) + fn matches(&self, input: &str) -> Option<(usize, Option)> { + match self { + EdgePattern::Literal(lit) => { + if input.starts_with(lit) { + Some((lit.len(), None)) + } else { + None + } + } + EdgePattern::PrefixOf(target) => { + // Check if input is a prefix of target + if target.starts_with(input) && !input.is_empty() { + // It's a valid prefix + Some((input.len(), None)) + } else if input.starts_with(target) { + // Full match + Some((target.len(), None)) + } else { + None + } + } + EdgePattern::Variable(var_name) => { + // Consume until next '/' or end of string + let end = input.find('/').unwrap_or(input.len()); + if end > 0 { + let captured = input[..end].to_string(); + // Validate based on variable type + if is_valid_variable(var_name, &captured) { + Some((end, Some(captured))) + } else { + None + } + } else { + None + } + } + EdgePattern::Any => { + // Match everything until next '/' or end + let end = input.find('/').unwrap_or(input.len()); + if end > 0 { + Some((end, Some(input[..end].to_string()))) + } else { + None + } + } + } + } + + /// Get the completion suggestion for this pattern + fn completion(&self, partial: &str) -> Option { + match self { + EdgePattern::PrefixOf(target) => { + if target.starts_with(partial) && partial != *target { + Some(target.to_string()) + } else { + None + } + } + _ => None, + } + } +} + +/// Edge in the graph +pub struct Edge { + pattern: EdgePattern, + target: NodeId, + /// Optional description for autocomplete + description: Option<&'static str>, +} + +/// Handler function for generating UI actions +type Handler = Box) -> UIAction>; + +/// Node in the graph +pub struct Node { + #[allow(dead_code)] + id: NodeId, + edges: Vec, + handler: Option, +} + +/// The composable parser graph +pub struct Graph { + nodes: HashMap>>, + root: NodeId, +} + +// --- Graph Builder (Fluent API) --- + +pub struct GraphBuilder { + nodes: HashMap>>, + current_node: Option, + root: NodeId, +} + +impl GraphBuilder { + pub fn new() -> Self { + let mut nodes = HashMap::new(); + let root_node = Rc::new(RefCell::new(Node { + id: "root", + edges: Vec::new(), + handler: None, + })); + nodes.insert("root", root_node); + + GraphBuilder { + nodes, + current_node: Some("root"), + root: "root", + } + } + + /// Select a node to add edges to + pub fn at(mut self, node_id: NodeId) -> Self { + // Create node if it doesn't exist + if !self.nodes.contains_key(node_id) { + let node = Rc::new(RefCell::new(Node { + id: node_id, + edges: Vec::new(), + handler: None, + })); + self.nodes.insert(node_id, node); + } + self.current_node = Some(node_id); + self + } + + /// Add an edge from the current node + pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self { + self.edge_with_desc(pattern, target, None) + } + + /// Add an edge with description + pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self { + let current = self.current_node.expect("No current node selected"); + + // Create target node if it doesn't exist + if !self.nodes.contains_key(target) { + let node = Rc::new(RefCell::new(Node { + id: target, + edges: Vec::new(), + handler: None, + })); + self.nodes.insert(target, node); + } + + // Add edge to current node + if let Some(node) = self.nodes.get(current) { + node.borrow_mut().edges.push(Edge { + pattern, + target, + description: desc, + }); + } + + self + } + + /// Set handler for current node + pub fn handler(self, handler: F) -> Self + where + F: Fn(&str, &str, &HashMap) -> UIAction + 'static + { + let current = self.current_node.expect("No current node selected"); + if let Some(node) = self.nodes.get(current) { + node.borrow_mut().handler = Some(Box::new(handler)); + } + self + } + + /// Build the final graph + pub fn build(self) -> Graph { + Graph { + nodes: self.nodes, + root: self.root, + } + } +} + +// --- Parser Implementation --- + +impl Graph { + pub fn parse(&self, input: &str) -> UIAction { + let normalized = input.trim().to_lowercase(); + let mut state = ParserState { + input: &normalized, + cursor: 0, + current_node_id: self.root, + context: HashMap::new(), + original_query: input.to_string(), + current_prefix: String::new(), + }; + + self.parse_recursive(&mut state) + } + + fn parse_recursive(&self, state: &mut ParserState) -> UIAction { + let node = self.nodes.get(state.current_node_id) + .expect("Node not found in graph"); + let node_ref = node.borrow(); + + // If we've consumed all input, check for handler or suggestions + if state.cursor >= state.input.len() { + if let Some(handler) = &node_ref.handler { + return handler(&state.original_query, &state.current_prefix, &state.context); + } + + // No handler, try to suggest based on available edges + return self.suggest_from_edges(&node_ref, state); + } + + let remaining = &state.input[state.cursor..]; + + // Try to match each edge + for edge in &node_ref.edges { + if let Some((consumed, captured)) = edge.pattern.matches(remaining) { + // Save state for potential backtracking + let saved_cursor = state.cursor; + let saved_node = state.current_node_id; + let saved_prefix = state.current_prefix.clone(); + + // Update state + state.cursor += consumed; + state.current_node_id = edge.target; + state.current_prefix.push_str(&remaining[..consumed]); + + // Store captured variable if any + if let Some(value) = captured { + if let EdgePattern::Variable(var_name) = &edge.pattern { + state.context.insert(var_name.to_string(), value); + } + } + + // Check if this is a partial match that needs completion + if state.cursor == state.input.len() { + if let Some(completion_suffix) = edge.pattern.completion(remaining) { + // Use the current_prefix plus the completion suffix + let full_completion = format!("{}{}", + state.current_prefix, + completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix) + ); + return UIAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + + // Continue parsing from the target node + let result = self.parse_recursive(state); + + // If we got a valid response, return it + if !matches!(result, UIAction::ShowError(_)) { + return result; + } + + // Otherwise, restore state and try next edge + state.cursor = saved_cursor; + state.current_node_id = saved_node; + state.current_prefix = saved_prefix; + } + } + + // No edges matched - try to provide suggestions + self.suggest_from_edges(&node_ref, state) + } + + fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction { + let remaining = &state.input[state.cursor..]; + + // Find edges that could match with more input + for edge in &node.edges { + match &edge.pattern { + EdgePattern::PrefixOf(target) => { + if target.starts_with(remaining) && !remaining.is_empty() { + // Use current_prefix instead of rebuilding from input + let full_completion = format!("{}{}", state.current_prefix, target); + return UIAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + EdgePattern::Literal(lit) => { + if lit.starts_with(remaining) && !remaining.is_empty() { + let full_completion = format!("{}{}", state.current_prefix, lit); + return UIAction::suggest( + state.original_query.clone(), + Some(Suggestion { + text: full_completion.clone(), + completion: full_completion, + description: edge.description.map(|d| d.to_string()), + score: 1.0, + }) + ); + } + } + _ => {} + } + } + + UIAction::error( + "InvalidPath".to_string(), + format!("'{}' doesn't match any known pattern", state.original_query) + ) + } +} + +struct ParserState<'a> { + input: &'a str, + cursor: usize, + current_node_id: NodeId, + context: HashMap, + original_query: String, + current_prefix: String, +} + +// --- Helper Functions --- + +fn is_valid_variable(var_name: &str, value: &str) -> bool { + match var_name { + "subreddit" => { + !value.is_empty() && + value.len() <= 21 && + value.chars().all(|c| c.is_alphanumeric() || c == '_') + } + "username" => { + !value.is_empty() && + value.len() <= 20 && + value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') + } + "post_id" => { + !value.is_empty() && + value.len() <= 10 && + value.chars().all(|c| c.is_alphanumeric()) + } + _ => true, // Allow any value for unknown variables + } +} + +// --- Define the Reddit Graph --- + +pub fn build_reddit_graph() -> Graph { + GraphBuilder::new() + // === ROOT LEVEL: Direct aliases and domain/protocol patterns === + .at("root") + // Direct aliases to subreddit and user selection + .edge_with_desc( + EdgePattern::PrefixOf("r/"), + "subreddit_selection", + Some("Browse subreddits (e.g., r/programming)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("u/"), + "user_selection", + Some("Browse users (e.g., u/spez)") + ) + + // Reddit shortcuts - one pattern handles ALL prefixes! + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Go to Reddit") + ) + + // Protocol patterns - "h" can suggest "https://" + .edge_with_desc( + EdgePattern::PrefixOf("https://"), + "https_protocol", + Some("HTTPS protocol") + ) + .edge_with_desc( + EdgePattern::PrefixOf("http://"), + "http_protocol", + Some("HTTP protocol") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "www_prefix", + Some("World Wide Web") + ) + + + + // === HTTPS PROTOCOL: Can go to any domain === + .at("https_protocol") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit (HTTPS)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "https_www", + Some("WWW prefix") + ) + + // === HTTP PROTOCOL: Similar to HTTPS === + .at("http_protocol") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit (HTTP)") + ) + .edge_with_desc( + EdgePattern::PrefixOf("www."), + "http_www", + Some("WWW prefix") + ) + + // === HTTPS + WWW === + .at("https_www") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === HTTP + WWW === + .at("http_www") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === WWW PREFIX (without protocol) === + .at("www_prefix") + .edge_with_desc( + EdgePattern::PrefixOf("reddit.com"), + "reddit_domain", + Some("Reddit") + ) + + // === REDDIT DOMAIN: Expect "/" === + .at("reddit_domain") + .edge(EdgePattern::Literal("/"), "reddit_root") + .handler(|query, prefix, _ctx| { + // If someone just types "reddit.com" (or with protocol) without slash + // Suggest adding the slash using the current prefix + let completion = format!("{}/", prefix); + + UIAction::suggest( + query.to_string(), + Some(Suggestion { + text: completion.clone(), + completion, + description: Some("Continue to Reddit homepage".to_string()), + score: 1.0, + }) + ) + }) + + // === REDDIT ROOT: The main Reddit navigation === + .at("reddit_root") + .edge(EdgePattern::Literal("r/"), "subreddit_selection") + .edge(EdgePattern::Literal("u/"), "user_selection") + .handler(|query, prefix, _ctx| { + UIAction::multiple(vec![ + UIAction::scrolling_suggestions( + query.to_string(), + vec![ + ScrollingSuggestion { + completion: format!("{}r/", prefix), + }, + ScrollingSuggestion { + completion: format!("{}u/", prefix), + }, + ], + 1400, // 1.4 second interval (slower) + true // loop through + ), + UIAction::guide( + query.to_string(), + "Welcome to Sorter for Reddit".to_string(), + "Where would you like to start?".to_string(), + vec![ + GuideOption { + key: "r".to_string(), + label: "Sort a Subreddit".to_string(), + description: "Find the best posts in a community.".to_string(), + completion: format!("{}r/", prefix) + }, + GuideOption { + key: "u".to_string(), + label: "Sort User Content".to_string(), + description: "Explore and rank a user's posts and comments.".to_string(), + completion: format!("{}u/", prefix) + }, + ] + ), + ]) + }) + + + + // === SUBREDDIT SELECTION: THE UNIFIED NODE === + // This node is now reached from `r/` OR `reddit.com/r/` + .at("subreddit_selection") + .edge(EdgePattern::Variable("subreddit"), "subreddit_page") + .handler(|query, prefix, _ctx| { + UIAction::multiple(vec![ + UIAction::scrolling_suggestions( + query.to_string(), + vec![ + ScrollingSuggestion { + completion: format!("{}programming", prefix), + }, + ScrollingSuggestion { + completion: format!("{}askreddit", prefix), + }, + ScrollingSuggestion { + completion: format!("{}aww", prefix), + }, + ScrollingSuggestion { + completion: format!("{}rust", prefix), + }, + ScrollingSuggestion { + completion: format!("{}webdev", prefix), + }, + ], + 1600, // 1.6 second interval (slower) + true // loop through + ), + // Live DB-backed suggestions for subreddits as the user types + UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() } + ]) + }) + + // === Specific subreddit page === + .at("subreddit_page") + .edge(EdgePattern::Literal("/"), "subreddit_slash") + .handler(|_query, prefix, ctx| { + // Use the new unified subreddit resolution logic + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::ResolveAndDisplaySubreddit { + subreddit, + prefix: prefix.to_string(), + } + }) + + .at("subreddit_slash") + .edge(EdgePattern::Literal("hot"), "subreddit_hot") + .edge(EdgePattern::Literal("top"), "subreddit_top") + .edge(EdgePattern::Literal("new"), "subreddit_new") + .edge(EdgePattern::Literal("comments"), "subreddit_comments") + .handler(|query, prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::guide( + query.to_string(), + format!("What to sort in r/{}?", subreddit), + "Choose a category to begin sorting.".to_string(), + vec![ + GuideOption { + key: "hot".to_string(), + label: "Hot Posts".to_string(), + description: "Import and sort posts currently on the front page.".to_string(), + completion: format!("{}hot", prefix) + }, + GuideOption { + key: "top".to_string(), + label: "Top Posts".to_string(), + description: "Import and sort the highest-rated posts.".to_string(), + completion: format!("{}top", prefix) + }, + GuideOption { + key: "new".to_string(), + label: "New Posts".to_string(), + description: "Import and sort the newest posts.".to_string(), + completion: format!("{}new", prefix) + }, + GuideOption { + key: "comments".to_string(), + label: "All Comments".to_string(), + description: "Find the best comment across all imported threads.".to_string(), + completion: format!("{}comments/", prefix) + }, + ] + ) + }) + + + + .at("user_selection") + .edge(EdgePattern::Variable("username"), "user_profile") + .handler(|query, prefix, _ctx| { + UIAction::guide( + query.to_string(), + "User Profile Sorting".to_string(), + "Enter a Reddit username to sort their content.".to_string(), + vec![ + GuideOption { + key: "popular".to_string(), + label: "Popular Users".to_string(), + description: "Browse well-known Reddit users.".to_string(), + completion: prefix.to_string(), + }, + ] + ) + }) + + // === Subreddit sort types === + .at("subreddit_hot") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_top") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_new") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + .at("subreddit_comments") + .handler(|_query, _prefix, ctx| { + let subreddit = ctx.get("subreddit").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.subreddit".to_string(), + pk: subreddit, + } + }) + + // === User profile === + .at("user_profile") + .handler(|_query, _prefix, ctx| { + let username = ctx.get("username").cloned().unwrap_or_default(); + UIAction::RenderEntityView { + ns: "reddit.user".to_string(), + pk: username, + } + }) + + // === Build the graph === + .build() +} + +// --- Public API --- + +/// Parse a query string and return a UI action +pub fn parse_reddit_url(query: &str) -> UIAction { + let graph = build_reddit_graph(); + graph.parse(query) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Represents a keystroke action + #[derive(Debug, Clone, PartialEq)] + enum KeyAction { + Type(String), // Type characters + Tab, // Press tab (accept completion) + + } + + /// Expected state after a keystroke + #[derive(Debug, Clone)] + enum ExpectedAction { + Suggestion { completion: String }, + ScrollingSuggestions { completions: Vec }, + Guide { title_contains: String }, + + RenderSubreddit { subreddit: String, sort: Option }, + RenderSubredditComments { subreddit: String }, + RenderUser { username: String }, + ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution + Error { error_type: String }, + Multiple { expected_actions: Vec }, // Multiple responses with specific expectations + MultipleAny, // Multiple responses (any sub-actions - legacy) + DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions + + } + + impl ExpectedAction { + fn matches(&self, action: &UIAction) -> bool { + match (self, action) { + (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => { + data.suggestion.as_ref() + .map(|s| s.completion == *completion) + .unwrap_or(false) + } + (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => { + let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect(); + *completions == actual_completions + } + (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => { + title.contains(title_contains) + } + (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, + UIAction::RenderEntityView { ns, pk }) => { + ns == "reddit.subreddit" && pk == subreddit + } + (ExpectedAction::RenderSubredditComments { subreddit }, + UIAction::RenderEntityView { ns, pk }) => { + ns == "reddit.subreddit" && pk == subreddit + } + (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => { + ns == "reddit.user" && pk == username + } + (ExpectedAction::ResolveSubreddit { subreddit, prefix }, + UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => { + s == subreddit && p == prefix + } + (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => { + data.error_type == *error_type + } + (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => { + // Check that all expected actions are present + if expected_actions.len() != actions.len() { + return false; + } + expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| { + expected.matches(actual) + }) + } + (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true, + (ExpectedAction::DbSuggestions { partial, prefix }, + UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => { + p == partial && pr == prefix + } + + _ => false, + } + } + } + + /// Test helper to simulate a sequence of keystrokes + fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> { + let graph = build_reddit_graph(); + let mut current_text = String::new(); + let mut results = Vec::new(); + + for action in actions { + match action { + KeyAction::Type(text) => { + current_text.push_str(&text); + let result = graph.parse(¤t_text); + results.push((current_text.clone(), result)); + } + KeyAction::Tab => { + // Tab accepts the current suggestion if there is one + let result = graph.parse(¤t_text); + if let UIAction::ShowSuggestions(ref data) = result { + if let Some(ref suggestion) = data.suggestion { + current_text = suggestion.completion.clone(); + let new_result = graph.parse(¤t_text); + results.push((current_text.clone(), new_result)); + } + } + } + + } + } + + results + } + + /// Test a flow using declarative (KeyAction, ExpectedAction) tuples + fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) { + let graph = build_reddit_graph(); + let mut current_text = String::new(); + + println!("\n=== Flow: {} ===", name); + + for (i, (key_action, expected)) in flow.iter().enumerate() { + // Perform the keystroke + match key_action { + KeyAction::Type(text) => { + current_text.push_str(text); + } + KeyAction::Tab => { + // Tab accepts the current suggestion + let result = graph.parse(¤t_text); + if let UIAction::ShowSuggestions(data) = result { + if let Some(suggestion) = &data.suggestion { + current_text = suggestion.completion.clone(); + } + } + } + } + + // Check the result + let actual_action = graph.parse(¤t_text); + + println!(" Step {}: {:?} -> '{}' -> {:?}", + i + 1, key_action, current_text, actual_action); + + assert!( + expected.matches(&actual_action), + "Flow '{}' failed at step {}\n Expected: {:?}\n Actual: {:?}\n Text: '{}'", + name, i + 1, expected, actual_action, current_text + ); + } + + println!("✓ Flow '{}' passed!", name); + } + + /// Legacy helper for backward compatibility (will be removed) + fn assert_flow( + name: &str, + actions: Vec, + expected_checks: Vec bool>>, + ) { + let results = simulate_keystrokes(actions); + + println!("\n=== Flow: {} ===", name); + for (i, (text, action)) in results.iter().enumerate() { + println!(" Step {}: '{}' -> {:?}", i + 1, text, action); + + if i < expected_checks.len() { + let check = &expected_checks[i]; + assert!( + check(text, action), + "Flow '{}' failed at step {} with text '{}' and action {:?}", + name, i + 1, text, action + ); + } + } + println!("✓ Flow '{}' passed!", name); + } + + #[test] + fn test_https_reddit_tab_flow() { + // Test: typing "https://reddit.com" and pressing tab should give "https://reddit.com/" + assert_flow( + "HTTPS Reddit with Tab", + vec![ + KeyAction::Type("https://reddit.com".to_string()), + KeyAction::Tab, + ], + vec![ + Box::new(|text, action| { + // After typing "https://reddit.com", should get a suggestion + text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "https://reddit.com/" and show guide + text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + ], + ); + } + + #[test] + fn test_quick_subreddit_flow() { + // Test: "r" -> TAB -> "rust" (direct alias flow) + assert_flow( + "Quick Subreddit Access", + vec![ + KeyAction::Type("r".to_string()), + KeyAction::Tab, + KeyAction::Type("rust".to_string()), + ], + vec![ + Box::new(|text, action| { + // "r" should suggest "r/" + text == "r" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "r/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "r/" and show subreddit selection + text == "r/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "r/rust" should resolve the subreddit + text == "r/rust" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "rust" && prefix == "r/rust") + }), + ], + ); + } + + #[test] + fn test_progressive_completion_flow() { + // Test progressive typing: "r" -> "re" -> "red" -> "redd" -> "reddit" -> TAB + let progressive_actions = vec![ + KeyAction::Type("r".to_string()), + KeyAction::Type("e".to_string()), + KeyAction::Type("d".to_string()), + KeyAction::Type("d".to_string()), + KeyAction::Type("i".to_string()), + KeyAction::Type("t".to_string()), + KeyAction::Tab, + ]; + + let results = simulate_keystrokes(progressive_actions); + + println!("\n=== Progressive Completion Flow ==="); + for (i, (text, action)) in results.iter().enumerate() { + println!(" '{}' -> {:?}", text, action); + + // First step: "r" should suggest "r/" + if i == 0 && text == "r" { + match action { + UIAction::ShowSuggestions(data) => { + assert_eq!( + data.suggestion.as_ref().unwrap().completion, + "r/", + "Should suggest r/ at 'r'" + ); + } + _ => panic!("Expected suggestion at 'r'"), + } + } + // Other steps before tab should suggest "reddit.com" + else if i > 0 && i < results.len() - 1 { + match action { + UIAction::ShowSuggestions(data) => { + assert_eq!( + data.suggestion.as_ref().unwrap().completion, + "reddit.com", + "Should suggest reddit.com at '{}'", text + ); + } + _ => panic!("Expected suggestion at '{}'", text), + } + } + } + + // After tab, should have "reddit.com" + let (final_text, _) = results.last().unwrap(); + assert_eq!(final_text, "reddit.com"); + println!("✓ Progressive completion flow passed!"); + } + + #[test] + fn test_subreddit_sort_flow() { + // Test navigating to a subreddit and choosing a sort option + assert_flow( + "Subreddit Sort Navigation", + vec![ + KeyAction::Type("reddit.com/r/programming/hot".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/r/programming/hot" && + matches!(action, UIAction::RenderEntityView { ns, pk } + if ns == "reddit.subreddit" && pk == "programming") + }), + ], + ); + } + + #[test] + fn test_user_profile_flow() { + // Test navigating to a user profile + assert_flow( + "User Profile Navigation", + vec![ + KeyAction::Type("reddit.com/u/spez".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/u/spez" && + matches!(action, UIAction::RenderEntityView { ns, pk } if ns == "reddit.user" && pk == "spez") + }), + ], + ); + } + + #[test] + fn test_alias_shortcut_flow() { + // Test using the "r/" shortcut - now correctly goes directly to subreddit + assert_flow( + "Alias Shortcut", + vec![ + KeyAction::Type("r/".to_string()), + KeyAction::Type("technology".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "r/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + text == "r/technology" && + matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == "technology" && prefix == "r/technology") + }), + ], + ); + } + + #[test] + fn test_www_prefix_flow() { + // Test with www prefix + assert_flow( + "WWW Prefix", + vec![ + KeyAction::Type("www.reddit.com".to_string()), + KeyAction::Tab, + ], + vec![ + Box::new(|text, action| { + // Should suggest adding slash + text == "www.reddit.com" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "www.reddit.com/").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should show guide + text == "www.reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + ], + ); + } + + #[test] + fn test_invalid_path_handling() { + // Test that invalid paths show errors + assert_flow( + "Invalid Path", + vec![ + KeyAction::Type("reddit.com/invalid/path".to_string()), + ], + vec![ + Box::new(|text, action| { + text == "reddit.com/invalid/path" && + matches!(action, UIAction::ShowError(_)) + }), + ], + ); + } + + #[test] + fn test_complete_user_journey() { + // Test a complete user journey: type partial URL, tab complete, navigate to subreddit + assert_flow( + "Complete User Journey", + vec![ + KeyAction::Type("http".to_string()), + KeyAction::Type("s://r".to_string()), + KeyAction::Tab, + KeyAction::Type("/".to_string()), + KeyAction::Type("r/".to_string()), + KeyAction::Type("programming".to_string()), + KeyAction::Type("/".to_string()), + KeyAction::Type("top".to_string()), + ], + vec![ + Box::new(|text, action| { + // "http" should suggest "https://" + text == "http" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // "https://r" should suggest "https://reddit.com" + text == "https://r" && matches!(action, UIAction::ShowSuggestions(data) if + data.suggestion.as_ref().map(|s| s.completion == "https://reddit.com").unwrap_or(false) + ) + }), + Box::new(|text, action| { + // After tab, should have "https://reddit.com" + text == "https://reddit.com" && matches!(action, UIAction::ShowSuggestions(_)) + }), + Box::new(|text, action| { + // "https://reddit.com/" should show guide + text == "https://reddit.com/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/" should show subreddit selection + text == "https://reddit.com/r/" && matches!(action, UIAction::ShowMultiple { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming" should resolve subreddit + text == "https://reddit.com/r/programming" && + matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } + if subreddit == "programming" && prefix == "https://reddit.com/r/programming") + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming/" should show sort options + text == "https://reddit.com/r/programming/" && + matches!(action, UIAction::ShowStaticGuide { .. }) + }), + Box::new(|text, action| { + // "https://reddit.com/r/programming/top" should render entity view + text == "https://reddit.com/r/programming/top" && + matches!(action, UIAction::RenderEntityView { ns, pk } + if ns == "reddit.subreddit" && pk == "programming") + }), + ], + ); + } + + #[test] + fn test_multiple_tab_completions() { + // Test multiple tab completions in sequence + let actions = vec![ + KeyAction::Type("h".to_string()), + KeyAction::Tab, // Complete to "https://" + KeyAction::Type("r".to_string()), + KeyAction::Tab, // Complete to "https://reddit.com" + KeyAction::Type("/".to_string()), + ]; + + let results = simulate_keystrokes(actions); + + println!("\n=== Multiple Tab Completions ==="); + for (i, (text, _action)) in results.iter().enumerate() { + println!(" Step {}: '{}'", i + 1, text); + } + + // Verify the final state + assert_eq!(results[1].0, "https://"); // After first tab + assert_eq!(results[3].0, "https://reddit.com"); // After second tab + assert_eq!(results[4].0, "https://reddit.com/"); // After typing / + + println!("✓ Multiple tab completions work correctly!"); + } + + // === CONVENIENCE MACROS FOR CLEANER TESTS === + + macro_rules! flow { + ($(($key:expr, $expected:expr)),* $(,)?) => { + vec![$(($key, $expected)),*] + }; + } + + macro_rules! type_text { + ($text:expr) => { + KeyAction::Type($text.to_string()) + }; + } + + macro_rules! suggests { + ($completion:expr) => { + ExpectedAction::Suggestion { completion: $completion.to_string() } + }; + } + + macro_rules! renders_subreddit { + ($subreddit:expr) => { + ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None } + }; + ($subreddit:expr, $sort:expr) => { + ExpectedAction::RenderSubreddit { + subreddit: $subreddit.to_string(), + sort: Some($sort.to_string()) + } + }; + } + + macro_rules! renders_subreddit_comments { + ($subreddit:expr) => { + ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() } + }; + } + + macro_rules! resolves_subreddit { + ($subreddit:expr, $prefix:expr) => { + ExpectedAction::ResolveSubreddit { + subreddit: $subreddit.to_string(), + prefix: $prefix.to_string() + } + }; + } + + macro_rules! shows_guide { + ($title_contains:expr) => { + ExpectedAction::Guide { title_contains: $title_contains.to_string() } + }; + } + + macro_rules! multiple { + ($($action:expr),* $(,)?) => { + ExpectedAction::Multiple { expected_actions: vec![$($action),*] } + }; + } + + macro_rules! scrolling_suggestions { + ($($completion:expr),* $(,)?) => { + ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] } + }; + } + + macro_rules! db_suggestions { + ($partial:expr, $prefix:expr) => { + ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() } + }; + } + + // === NEW DECLARATIVE TESTS === + + #[test] + fn test_declarative_https_tab_flow() { + test_flow("HTTPS Tab Completion", vec![ + (KeyAction::Type("https://reddit.com".to_string()), + ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), + (KeyAction::Tab, + multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_declarative_quick_subreddit_flow() { + test_flow("Quick Subreddit Flow", vec![ + (KeyAction::Type("r".to_string()), + ExpectedAction::Suggestion { completion: "r/".to_string() }), + (KeyAction::Tab, + ExpectedAction::MultipleAny), // r/ shows subreddit selection + (KeyAction::Type("rust".to_string()), + ExpectedAction::ResolveSubreddit { subreddit: "rust".to_string(), prefix: "r/rust".to_string() }), + ]); + } + + #[test] + fn test_declarative_complete_journey() { + test_flow("Complete User Journey", vec![ + (KeyAction::Type("http".to_string()), + ExpectedAction::Suggestion { completion: "https://".to_string() }), + (KeyAction::Type("s://r".to_string()), + ExpectedAction::Suggestion { completion: "https://reddit.com".to_string() }), + (KeyAction::Tab, + ExpectedAction::Suggestion { completion: "https://reddit.com/".to_string() }), + (KeyAction::Type("/r/programming/top".to_string()), + ExpectedAction::RenderSubreddit { + subreddit: "programming".to_string(), + sort: Some("top".to_string()) + }), + ]); + } + + #[test] + fn test_declarative_user_profile() { + test_flow("User Profile Navigation", vec![ + (KeyAction::Type("reddit.com/u/spez".to_string()), + ExpectedAction::RenderUser { username: "spez".to_string() }), + ]); + } + + #[test] + fn test_declarative_error_handling() { + test_flow("Error Handling", vec![ + (KeyAction::Type("reddit.com/invalid/path".to_string()), + ExpectedAction::Error { error_type: "InvalidPath".to_string() }), + ]); + } + + #[test] + fn test_declarative_progressive_completion() { + test_flow("Progressive Completion", vec![ + (KeyAction::Type("r".to_string()), + ExpectedAction::Suggestion { completion: "r/".to_string() }), + (KeyAction::Type("e".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Type("d".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Type("dit".to_string()), + ExpectedAction::Suggestion { completion: "reddit.com".to_string() }), + (KeyAction::Tab, + ExpectedAction::Suggestion { completion: "reddit.com/".to_string() }), + ]); + } + + #[test] + fn test_declarative_subreddit_guide() { + test_flow("Subreddit Guide", vec![ + (KeyAction::Type("reddit.com/r/programming/".to_string()), + ExpectedAction::Guide { title_contains: "What to sort".to_string() }), + ]); + } + + #[test] + fn test_clean_macro_example() { + // This is what the tests can look like with macros! + test_flow("Clean Macro Example", flow![ + (type_text!("r"), suggests!("r/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + (type_text!("rust/hot"), renders_subreddit!("rust", "hot")), + ]); + } + + #[test] + fn test_unified_subreddit_resolution_flow() { + // This test demonstrates the new unified behavior: + // - r/programming (exact) → tries exact match first + // - r/pro (partial) → tries exact match, then suggestions + TAB completion + + test_flow("Unified Resolution: Exact Match", flow![ + (type_text!("r/programming"), resolves_subreddit!("programming", "r/programming")), + ]); + + test_flow("Unified Resolution: Partial Match", flow![ + (type_text!("r/pro"), resolves_subreddit!("pro", "r/pro")), + ]); + + // Both go through the same action type, but dispatcher handles them differently: + // - If "programming" exists in DB → shows EntityView immediately + // - If "pro" doesn't exist in DB → shows Multiple with: + // 1. Suggestions (for TAB completion to best match) + // 2. Selection (for clickable options including import) + } + + #[test] + fn test_tab_completion_workflow_unified() { + // This demonstrates the desired TAB completion behavior: + // r/pro + TAB → r/programming (if "programming" is the best DB match) + + // Note: This test shows the PARSER behavior. The actual TAB completion + // happens in the frontend when it receives the Multiple response containing + // both Suggestions (for TAB) and Selection (for click options). + + let graph = build_reddit_graph(); + + // 1. Parser generates unified action for partial input + match graph.parse("r/pro") { + UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "pro"); + assert_eq!(prefix, "r/pro"); + println!("✓ Parser correctly identifies 'r/pro' as subreddit resolution"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for 'r/pro'"), + } + + // 2. When dispatcher runs (in real app), it will return Multiple response with: + // - Suggestions: { completion: "r/programming" } (for TAB) + // - Selection: [ "Import r/pro", "r/programming", ... ] (for clicks) + + println!("✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)"); + } + + #[test] + fn test_ultra_clean_user_journey() { + test_flow("Ultra Clean User Journey", flow![ + (type_text!("h"), suggests!("https://")), + (type_text!("ttps://r"), suggests!("https://reddit.com")), + (KeyAction::Tab, suggests!("https://reddit.com/")), + (type_text!("/u/spez"), ExpectedAction::RenderUser { username: "spez".to_string() }), + ]); + } + + // === TESTS FROM parser.tdsl === + + #[test] + fn test_tdsl_basic_reddit_progression() { + // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps + test_flow("TDSL Basic Reddit Progression", flow![ + (type_text!("r"), suggests!("r/")), + (type_text!("e"), suggests!("reddit.com")), + (type_text!("d"), suggests!("reddit.com")), + (type_text!("d"), suggests!("reddit.com")), + (type_text!("i"), suggests!("reddit.com")), + (type_text!("t"), suggests!("reddit.com")), + (type_text!("."), suggests!("reddit.com")), + (type_text!("c"), suggests!("reddit.com")), + (type_text!("o"), suggests!("reddit.com")), + (KeyAction::Tab, suggests!("reddit.com/")), + ]); + } + + #[test] + fn test_tdsl_reddit_com_slash_infographic() { + // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} + test_flow("TDSL Reddit.com/ Infographic", flow![ + (type_text!("reddit.com/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_selection() { + // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} + test_flow("TDSL Subreddit Selection", flow![ + (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_view() { + // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)} + test_flow("TDSL Subreddit View", flow![ + (type_text!("reddit.com/r/programming"), resolves_subreddit!("programming", "reddit.com/r/programming")), + ]); + } + + #[test] + fn test_tdsl_subreddit_slash_infographic() { + // reddit.com/r/{sub}/ -> {show infographic or something} + test_flow("TDSL Subreddit Slash Infographic", flow![ + (type_text!("reddit.com/r/programming/"), shows_guide!("What to sort")), + ]); + } + + #[test] + fn test_tdsl_subreddit_comments() { + // reddit.com/r/{sub}/comments/{randomly chose comment from sql} + test_flow("TDSL Subreddit Comments", flow![ + (type_text!("reddit.com/r/programming/comments"), renders_subreddit_comments!("programming")), + ]); + } + + #[test] + fn test_tdsl_h_to_https() { + // h->https:// (show supported domains) + test_flow("TDSL H to HTTPS", flow![ + (type_text!("h"), suggests!("https://")), + ]); + } + + #[test] + fn test_tdsl_composable_https_reddit() { + // https://r->https://reddit.com/ + test_flow("TDSL Composable HTTPS Reddit", flow![ + (type_text!("https://r"), suggests!("https://reddit.com")), + ]); + } + + #[test] + fn test_tdsl_composable_https_www() { + // https://w->https://www. + test_flow("TDSL Composable HTTPS WWW", flow![ + (type_text!("https://w"), suggests!("https://www.")), + ]); + } + + #[test] + fn test_tdsl_composable_https_www_reddit() { + // https://www.r->https://www.reddit.com/ + test_flow("TDSL Composable HTTPS WWW Reddit", flow![ + (type_text!("https://www.r"), suggests!("https://www.reddit.com")), + ]); + } + + #[test] + fn test_tdsl_full_composable_chain() { + // Complete chain showing composability: h -> https:// -> https://www.reddit.com + test_flow("TDSL Full Composable Chain Step 1", flow![ + (type_text!("h"), suggests!("https://")), + ]); + + test_flow("TDSL Full Composable Chain Step 2", flow![ + (type_text!("https://w"), suggests!("https://www.")), + ]); + + test_flow("TDSL Full Composable Chain Step 3", flow![ + (type_text!("https://www.r"), suggests!("https://www.reddit.com")), + ]); + } + + #[test] + fn test_tdsl_progressive_reddit_paths() { + // Test various reddit paths work as expected + test_flow("TDSL Progressive Reddit Paths", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_tdsl_subreddit_sorting_options() { + // Test that subreddit sorting options work as described + test_flow("TDSL Subreddit Sorting", flow![ + (type_text!("reddit.com/r/rust/hot"), renders_subreddit!("rust", "hot")), + ]); + + test_flow("TDSL Subreddit Top", flow![ + (type_text!("reddit.com/r/rust/top"), renders_subreddit!("rust", "top")), + ]); + + test_flow("TDSL Subreddit New", flow![ + (type_text!("reddit.com/r/rust/new"), renders_subreddit!("rust", "new")), + ]); + } + + #[test] + fn test_tdsl_all_reddit_prefixes() { + // Test all the prefixes mentioned in parser.tdsl work + // "r" now suggests "r/", all others suggest "reddit.com" + let prefixes_to_reddit = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; + + // Test "r" separately since it now suggests "r/" + test_flow("TDSL Prefix: r", flow![ + (type_text!("r"), suggests!("r/")), + ]); + + for prefix in prefixes_to_reddit { + test_flow(&format!("TDSL Prefix: {}", prefix), flow![ + (type_text!(prefix), suggests!("reddit.com")), + ]); + } + } + + #[test] + fn test_tdsl_protocol_combinations() { + // Test various protocol combinations from parser.tdsl + let test_cases = vec![ + ("http://r", "http://reddit.com"), + ("https://r", "https://reddit.com"), + ("www.r", "www.reddit.com"), + ("https://www.r", "https://www.reddit.com"), + ("http://www.r", "http://www.reddit.com"), + ]; + + for (input, expected) in test_cases { + test_flow(&format!("TDSL Protocol: {}", input), flow![ + (type_text!(input), suggests!(expected)), + ]); + } + } + + #[test] + fn test_tdsl_edge_case_completions() { + // Test edge cases mentioned in parser.tdsl + test_flow("TDSL Reddit.com completion", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + ]); + + // Test that typing full reddit.com suggests the slash + test_flow("TDSL Full domain completion", flow![ + (type_text!("reddit.com"), suggests!("reddit.com/")), + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_real_user_trace_2025_08_07_fixed() { + // Based on actual WebSocket trace from 2025-08-07T01:35:46Z + // This test shows the CORRECT behavior after fixing the protocol preservation bug + test_flow("Real User Trace: Progressive Typing with Tab Completions (Fixed)", flow![ + // User started typing "h" + (type_text!("h"), suggests!("https://")), + + // User continued to "ht" + (type_text!("t"), suggests!("https://")), + + // User finished typing "https://" (trace shows full protocol) + (type_text!("tps://"), suggests!("https://")), + + // User started typing "r" after protocol + (type_text!("r"), suggests!("https://reddit.com")), + + // User continued typing "re" + (type_text!("e"), suggests!("https://reddit.com")), + + // User typed out or completed "https://reddit.com" + (type_text!("ddit.com"), suggests!("https://reddit.com/")), + + // User accepted completion to "https://reddit.com/" + // NOW suggestions should preserve the https:// protocol + (KeyAction::Tab, multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // This is the key fix! + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_protocol_preservation_bug_fix() { + // This test specifically verifies the fix for the protocol preservation bug + test_flow("Protocol Preservation: HTTPS Reddit Homepage", flow![ + (type_text!("https://reddit.com/"), multiple![ + scrolling_suggestions!("https://reddit.com/r/", "https://reddit.com/u/"), // Should preserve https:// + shows_guide!("Welcome to Sorter") + ]), + ]); + + // Test that subreddit selection preserves protocol + test_flow("Protocol Preservation: HTTPS Subreddit Selection", flow![ + (type_text!("https://reddit.com/r/"), multiple![ + scrolling_suggestions!("https://reddit.com/r/programming", "https://reddit.com/r/askreddit", "https://reddit.com/r/aww", "https://reddit.com/r/rust", "https://reddit.com/r/webdev"), // Should preserve https:// + db_suggestions!("https://reddit.com/r/", "https://reddit.com/r/") + ]), + ]); + + // Test with different protocols + test_flow("Protocol Preservation: HTTP", flow![ + (type_text!("http://reddit.com/"), multiple![ + scrolling_suggestions!("http://reddit.com/r/", "http://reddit.com/u/"), // Should preserve http:// + shows_guide!("Welcome to Sorter") + ]), + ]); + + test_flow("Protocol Preservation: WWW", flow![ + (type_text!("www.reddit.com/"), multiple![ + scrolling_suggestions!("www.reddit.com/r/", "www.reddit.com/u/"), // Should preserve www. + shows_guide!("Welcome to Sorter") + ]), + ]); + + // Test that plain reddit.com still works + test_flow("Protocol Preservation: Plain Domain", flow![ + (type_text!("reddit.com/"), multiple![ + scrolling_suggestions!("reddit.com/r/", "reddit.com/u/"), // No protocol prefix + shows_guide!("Welcome to Sorter") + ]), + ]); + } + + #[test] + fn test_user_navigation_pattern() { + // Models how users actually navigate: type → tab → click suggestion → end up at destination + // This captures the "jump" from https://reddit.com/ to reddit.com/r/ seen in the trace + test_flow("User Navigation: Protocol to Domain", flow![ + // User types and gets to homepage + (type_text!("https://reddit.com/"), ExpectedAction::MultipleAny), + ]); + + // Then they navigate (perhaps clicking a suggestion) to subreddit selection + test_flow("User Navigation: Click to Subreddit", flow![ + // This is where they ended up - the suggestion in the guide probably said "reddit.com/r/" + (type_text!("reddit.com/r/"), ExpectedAction::MultipleAny), + ]); + } + + #[test] + fn test_progressive_typing_pattern() { + // Based on the trace pattern - users often type character by character + // This tests the exact sequence of suggestions they would see + test_flow("Progressive Typing Pattern", flow![ + (type_text!("h"), suggests!("https://")), + (type_text!("t"), suggests!("https://")), // Still suggests https:// + (type_text!("t"), suggests!("https://")), // ht -> htt, still suggests https:// + (type_text!("p"), suggests!("https://")), // http should still suggest https:// + (type_text!("s"), suggests!("https://")), // https should suggest https:// + (type_text!("://"), suggests!("https://")), // Even complete protocol still suggests itself + ]); + } + + #[test] + fn test_tab_completion_workflow() { + // Test what happens when user uses tab completions strategically + test_flow("Strategic Tab Completion Workflow", flow![ + // Start typing, get suggestion + (type_text!("h"), suggests!("https://")), + + // Accept suggestion with tab - this should move us to "https://" + (KeyAction::Tab, suggests!("https://")), // After tab, we're at "https://" which still suggests itself + + // Start typing reddit + (type_text!("r"), suggests!("https://reddit.com")), + + // Accept reddit suggestion + (KeyAction::Tab, suggests!("https://reddit.com/")), + + // Accept final suggestion to get to homepage + (KeyAction::Tab, ExpectedAction::MultipleAny), + ]); + } + + // Keep the original tests as well + #[test] + fn test_reddit_prefix_autocomplete() { + let graph = build_reddit_graph(); + + // "r" now suggests "r/", others suggest "reddit.com" + match graph.parse("r") { + UIAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, "r/"); + println!("✓ 'r' → r/"); + } + _ => panic!("Expected suggestion for 'r'"), + } + + // Test other prefixes that should suggest "reddit.com" + let prefixes = vec!["re", "red", "redd", "reddi", "reddit", "reddit.", "reddit.c", "reddit.co"]; + + for prefix in prefixes { + match graph.parse(prefix) { + UIAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, "reddit.com"); + println!("✓ '{}' → reddit.com", prefix); + } + _ => panic!("Expected suggestion for '{}'", prefix), + } + } + } + + #[test] + fn test_protocol_composition() { + let graph = build_reddit_graph(); + + // Test protocol + reddit compositions + let tests = vec![ + ("https://r", "https://reddit.com"), + ("https://re", "https://reddit.com"), + ("https://reddit", "https://reddit.com"), + ("https://www.r", "https://www.reddit.com"), + ("https://www.reddit", "https://www.reddit.com"), + ("http://r", "http://reddit.com"), + ("www.r", "www.reddit.com"), + ]; + + for (input, expected) in tests { + match graph.parse(input) { + UIAction::ShowSuggestions(data) => { + assert_eq!(data.suggestion.as_ref().unwrap().completion, expected); + println!("✓ '{}' → {}", input, expected); + } + _ => panic!("Expected suggestion for '{}'", input), + } + } + } + + #[test] + fn test_alias_and_full_paths() { + let graph = build_reddit_graph(); + + // reddit.com/ should show guide, r/ should show subreddit selection + match graph.parse("reddit.com/") { + UIAction::ShowMultiple { actions } => { + let has_guide = actions.iter() + .any(|a| matches!(a, UIAction::ShowStaticGuide { .. })); + assert!(has_guide, "Path 'reddit.com/' should show guide"); + println!("✓ 'reddit.com/' shows Reddit root guide"); + } + _ => panic!("Expected Multiple action for 'reddit.com/'"), + } + + match graph.parse("r/") { + UIAction::ShowMultiple { actions } => { + let has_scrolling_suggestions = actions.iter() + .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. })); + let has_db_suggestions = actions.iter() + .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. })); + assert!(has_scrolling_suggestions && has_db_suggestions, + "Path 'r/' should show scrolling suggestions and DB suggestions"); + println!("✓ 'r/' shows subreddit selection"); + } + _ => panic!("Expected Multiple action for 'r/'"), + } + } + + #[test] + fn test_deep_navigation() { + let graph = build_reddit_graph(); + + // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit + match graph.parse("reddit.com/r/rust") { + UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "rust"); + assert_eq!(prefix, "reddit.com/r/rust"); + println!("✓ reddit.com/r/rust recognized"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit"), + } + + // Test with alias (r/ goes directly to subreddit, no double r/) + match graph.parse("r/programming") { + UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => { + assert_eq!(subreddit, "programming"); + assert_eq!(prefix, "r/programming"); + println!("✓ r/programming (alias) recognized"); + } + _ => panic!("Expected ResolveAndDisplaySubreddit for subreddit via alias"), + } + } +} diff --git a/parser.tdsl b/parser.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..03ba9563ea8c73b7411cd1aa42dda9c7c313cd39 --- /dev/null +++ b/parser.tdsl @@ -0,0 +1,32 @@ +The parser is a series of edges/transitions, for example: +r -> reddit.com/ +where -> means that all of + r->reddit.com/ + re->reddit.com/ + red->reddit.com/ + redd->reddit.com/ + reddi->reddit.com/ + reddit->reddit.com/ + reddit.->reddit.com/ + reddit.c->reddit.com/ + reddit.co->reddit.com/ + reddit.com->reddt.com/ + are defined as autocomplete suggestions + +re -> reddit.com/ +reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/ +reddit.com/r/ -> reddit.com/r/{randomly chose sub from list} +reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/ +reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql} + + +h->https:// (show supported domains) + +// the edges are composable such that this is possible, while reusing the above reddit code. +https://r->https://reddit.com/ +https://w->https://www. +https://www.r->https://www.reddit.com/ + + +// and i also want to have commands after you press space, like +reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% diff --git a/ranking.rs b/ranking.rs new file mode 100644 index 0000000000000000000000000000000000000000..34241cf6bf13117b3ad3262761eb7f7b48b54d80 --- /dev/null +++ b/ranking.rs @@ -0,0 +1,393 @@ +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/ranking.rs +use std::collections::{HashMap, HashSet}; + +use crate::path_types::ItemId; +use crate::reducer::GroupState; + +#[derive(Debug, Clone)] +pub struct RankedItem { + pub item: ItemId, + pub score: f64, +} + +/// Compute connected components over the voted-pairs graph (treated as undirected). +/// +/// Returns: +/// - `components`: each component is a sorted list of node indices, excluding isolates. +/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs). +pub fn connected_components_from_voted_pairs( + n: usize, + voted_pairs: impl Iterator, +) -> (Vec>, Vec) { + let mut adj: Vec> = vec![Vec::new(); n]; + for (a, b) in voted_pairs { + if a >= n || b >= n || a == b { + continue; + } + adj[a].push(b); + adj[b].push(a); + } + + let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect(); + isolates.sort(); + + let mut seen = vec![false; n]; + for &i in &isolates { + seen[i] = true; + } + + let mut comps: Vec> = Vec::new(); + for i in 0..n { + if seen[i] { + continue; + } + let mut stack = vec![i]; + seen[i] = true; + let mut comp: Vec = Vec::new(); + while let Some(x) = stack.pop() { + comp.push(x); + for &y in &adj[x] { + if !seen[y] { + seen[y] = true; + stack.push(y); + } + } + } + comp.sort(); + comps.push(comp); + } + + (comps, isolates) +} + +/// Compute rank centrality scores for a GroupState. +/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing +/// an O(E) multiply per iteration. +pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) { + if !group.dirty && !group.cached_scores.is_empty() { + return; + } + + let n = group.idx_to_item.len(); + if n == 0 { + group.cached_scores = vec![]; + group.dirty = false; + return; + } + if n == 1 { + group.cached_scores = vec![1.0]; + group.dirty = false; + return; + } + + let scores = compute_scores_from_edges( + n, + group.edges.iter().map(|(&k, &w)| (k, w)), + max_iters, + tol, + ); + group.cached_scores = scores; + group.dirty = false; +} + +pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec { + compute_group_ranking(group, max_iters, tol); + let mut items: Vec = group + .idx_to_item + .iter() + .enumerate() + .map(|(i, item)| RankedItem { + item: item.clone(), + score: *group.cached_scores.get(i).unwrap_or(&0.0), + }) + .collect(); + + items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + items +} + +fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec { + if n == 0 { + return vec![]; + } + if n == 1 { + return vec![1.0]; + } + + // Collect raw edges into a map for pairwise normalization. + let mut raw: HashMap<(usize, usize), f64> = HashMap::new(); + for ((src, dst), w) in edges { + if src >= n || dst >= n || w <= 0.0 { + continue; + } + *raw.entry((src, dst)).or_insert(0.0) += w; + } + + // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji). + // This ensures repeated votes on the same pair don't inflate influence + // beyond what the ratio implies. + let keys: Vec<(usize, usize)> = raw.keys().copied().collect(); + let mut normalized: HashMap<(usize, usize), f64> = HashMap::new(); + for (i, j) in keys { + if normalized.contains_key(&(i, j)) { + continue; + } + let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0); + let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0); + let total = w_ij + w_ji; + if total <= 0.0 { + continue; + } + normalized.insert((i, j), w_ij / total); + if w_ji > 0.0 { + normalized.insert((j, i), w_ji / total); + } + } + + // Rank Centrality (Negahban, Oh, Shah 2012, §3.1): + // P_ij = (1/d_max) * A_ij for i ≠ j compared + // P_ii = 1 - (1/d_max) * Σ_k A_ik + // where d_i is the *degree* (number of distinct neighbors compared) and + // d_max = max_i d_i. Using the unweighted degree — not the sum of + // pairwise-normalized weights — is what guarantees aperiodicity: it + // forces P_ii > 0 for every non-maximum-degree node, and for max-degree + // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous + // loss). Without this, regular comparison graphs (e.g. a pure star at + // ratio 2:1) produce a bipartite chain that oscillates instead of + // converging — see issue #146. + let mut out_edges: Vec> = vec![Vec::new(); n]; + let mut neighbors: Vec> = vec![HashSet::new(); n]; + + for ((src, dst), w) in &normalized { + out_edges[*src].push((*dst, *w)); + neighbors[*src].insert(*dst); + neighbors[*dst].insert(*src); + } + + let weight_sum: Vec = out_edges + .iter() + .map(|es| es.iter().map(|(_, w)| *w).sum()) + .collect(); + let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0); + if d_max == 0 { + return vec![1.0 / n as f64; n]; + } + let d_max_f = d_max as f64; + + let mut scores = vec![1.0 / n as f64; n]; + let mut next = vec![0.0f64; n]; + + for _ in 0..max_iters { + next.fill(0.0); + for i in 0..n { + let stay_prob = (d_max_f - weight_sum[i]) / d_max_f; + next[i] += scores[i] * stay_prob; + + if out_edges[i].is_empty() { + continue; + } + for &(dst, w) in &out_edges[i] { + next[dst] += scores[i] * (w / d_max_f); + } + } + + let diff: f64 = scores + .iter() + .zip(next.iter()) + .map(|(a, b)| (a - b).abs()) + .sum(); + + scores.clone_from_slice(&next); + if diff < tol { + break; + } + } + + let sum: f64 = scores.iter().sum(); + if sum.is_finite() && sum > 0.0 { + for s in &mut scores { + *s /= sum; + } + } + scores +} + +/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges. +/// +/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names. +pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec { + if idxs.is_empty() { + return vec![]; + } + + // Map original idx -> compact idx [0..m) + let mut map: HashMap = HashMap::with_capacity(idxs.len()); + for (j, &i) in idxs.iter().enumerate() { + map.insert(i, j); + } + + let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| { + let s = *map.get(&src)?; + let d = *map.get(&dst)?; + Some(((s, d), w)) + }); + + let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol); + + // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe). + let mut items: Vec = idxs + .iter() + .enumerate() + .filter_map(|(j, &orig)| { + let item = group.idx_to_item.get(orig)?.clone(); + Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) }) + }) + .collect(); + + items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + items +} + +pub fn group_summary_scores( + group: &mut GroupState, + max_iters: usize, + tol: f64, +) -> HashMap { + ranked_items(group, max_iters, tol) + .into_iter() + .map(|r| (r.item, r.score)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::reducer::VoteData; + + fn mk_group() -> GroupState { + GroupState::new() + } + + fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData { + use crate::path_types::ItemId; + VoteData { + ts, + a: ItemId::parse(a).unwrap(), + b: ItemId::parse(b).unwrap(), + ratio_left: l, + ratio_right: r, + body: "because".to_string(), + principal: "test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), + thread_tag: "untagged".to_string(), + } + } + + /// Regression for issue #146: pure forward star at default `>` ratio (2:1). + /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the + /// chain was bipartite; power iteration oscillated and returned the + /// uniform initial distribution after an even number of steps. Using the + /// paper's degree-based d_max gives every node a positive self-loop and + /// the chain converges to the correct stationary distribution. + #[test] + fn star_topology_winner_at_top_via_subset() { + let mut g = mk_group(); + g.apply_vote(vote(1, "zebra", "alpha", 2, 1)); + g.apply_vote(vote(2, "zebra", "beta", 2, 1)); + + let mut items: Vec<(usize, String)> = g + .idx_to_item + .iter() + .enumerate() + .map(|(i, it)| (i, it.as_str().to_string())) + .collect(); + items.sort_by(|a, b| a.1.cmp(&b.1)); + let idxs: Vec = items.iter().map(|(i, _)| *i).collect(); + + let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8); + for r in &ranked { + eprintln!("{}: {}", r.item.as_str(), r.score); + } + assert_eq!( + ranked[0].item.as_str(), + "https://slug.social/zebra", + "zebra won both votes and should rank #1" + ); + } + + #[test] + fn group_ranking_cache_dirty_flow() { + let mut g = mk_group(); + assert!(g.dirty); + + g.apply_vote(vote(1, "a", "b", 3, 1)); + assert!(g.dirty); + assert!(g.cached_scores.is_empty()); + + compute_group_ranking(&mut g, 10000, 1e-8); + assert!(!g.dirty); + assert_eq!(g.cached_scores.len(), g.idx_to_item.len()); + + // Recomputing when not dirty should be a no-op. + let before = g.cached_scores.clone(); + compute_group_ranking(&mut g, 10000, 1e-8); + assert_eq!(before, g.cached_scores); + } + + #[test] + fn connected_components_split_disconnected_pairs() { + let mut g = mk_group(); + // Two disconnected edges: (a,b) and (c,d) + g.apply_vote(vote(1, "a", "b", 3, 1)); + g.apply_vote(vote(2, "c", "d", 3, 1)); + + let n = g.idx_to_item.len(); + let (mut comps, isolates) = + connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied()); + assert!(isolates.is_empty()); + // Order-independent: sort components by their item names for stable assert. + comps.sort_by_key(|c| { + c.iter() + .map(|&i| g.idx_to_item[i].clone()) + .collect::>() + }); + assert_eq!(comps.len(), 2); + let comp0 = comps[0] + .iter() + .map(|&i| g.idx_to_item[i].as_str()) + .collect::>(); + let comp1 = comps[1] + .iter() + .map(|&i| g.idx_to_item[i].as_str()) + .collect::>(); + assert_eq!(comp0, vec!["https://slug.social/a", "https://slug.social/b"]); + assert_eq!(comp1, vec!["https://slug.social/c", "https://slug.social/d"]); + } + + #[test] + fn subset_ranking_ranks_within_component_only() { + let mut g = mk_group(); + g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b + g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c + + let (comps, _) = + connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied()); + assert_eq!(comps.len(), 2); + + // Rank each component and ensure winner is first within that component. + for comp in comps { + let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8); + assert_eq!(ranked.len(), 2); + let names = ranked.iter().map(|r| r.item.as_str()).collect::>(); + if names.contains(&"https://slug.social/a") { + assert_eq!(names[0], "https://slug.social/a"); + } else { + assert_eq!(names[0], "https://slug.social/d"); + } + } + } +} + + +tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ + diff --git a/reducer.rs b/reducer.rs new file mode 100644 index 0000000000000000000000000000000000000000..1ba8030feaf783b066b2e0bba4d2d05659c992f4 --- /dev/null +++ b/reducer.rs @@ -0,0 +1,845 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use serde::{Deserialize, Serialize}; + +use crate::canonical_path::canonicalize_tag; +use crate::dsl; +use crate::events::{Event, Ingest, ThreadCapability}; +use crate::path_types::ItemId; + +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub enum ScopeId { + Public, + Room(String), +} + +/// Wire `room` field → content scope (`"public"` → [`ScopeId::Public`]). +pub fn scope_from_room_wire(room: &str) -> ScopeId { + let r = room.trim(); + if r.is_empty() || r == "public" { + ScopeId::Public + } else { + ScopeId::Room(r.to_string()) + } +} + +/// Parsed vote data (internal representation). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct VoteData { + pub ts: i64, + pub a: ItemId, + pub b: ItemId, + pub ratio_left: i32, + pub ratio_right: i32, + pub body: String, + pub principal: String, + pub delegate: Option, + /// Forum channel where this vote was cast (tag only, not room id). + pub thread_tag: String, +} + +#[derive(Debug, Clone)] +pub struct GroupState { + pub item_to_idx: HashMap, + pub idx_to_item: Vec, + + /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight. + pub edges: HashMap<(usize, usize), f64>, + + /// Unordered pairs that have at least one vote recorded between them (i, + pub dirty: bool, + pub cached_scores: Vec, + pub recent_votes: VecDeque, +} + +impl Default for GroupState { + fn default() -> Self { + Self::new() + } +} + +impl GroupState { + pub fn new() -> Self { + Self { + item_to_idx: HashMap::new(), + idx_to_item: Vec::new(), + edges: HashMap::new(), + voted_pairs: HashSet::new(), + dirty: true, + cached_scores: Vec::new(), + recent_votes: VecDeque::with_capacity(200), + } + } + + fn ensure_item(&mut self, item: &ItemId) -> usize { + if let Some(&idx) = self.item_to_idx.get(item) { + return idx; + } + let idx = self.idx_to_item.len(); + self.idx_to_item.push(item.clone()); + self.item_to_idx.insert(item.clone(), idx); + self.dirty = true; + idx + } + + /// Public test helper: insert an item into the group without a vote (for unit tests). + pub fn ensure_item_pub(&mut self, item: &str) -> usize { + if let Some(canon) = ItemId::parse(item) { + self.ensure_item(&canon) + } else { + // Fallback: treat as raw storage string + let canon = ItemId::opaque(item.to_string()); + self.ensure_item(&canon) + } + } + + fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) { + if w <= 0.0 { + return; + } + *self.edges.entry((src, dst)).or_insert(0.0) += w; + self.dirty = true; + } + + pub fn apply_vote(&mut self, mut vote: VoteData) { + vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone()); + vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone()); + vote.thread_tag = canonicalize_tag(&vote.thread_tag); + if vote.ratio_left < 0 { + vote.ratio_left = 0; + } + if vote.ratio_right < 0 { + vote.ratio_right = 0; + } + let a_idx = self.ensure_item(&vote.a); + let b_idx = self.ensure_item(&vote.b); + + let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) }; + self.voted_pairs.insert((i, j)); + + let w_a = vote.ratio_left as f64; + let w_b = vote.ratio_right as f64; + + self.add_edge_weight(b_idx, a_idx, w_a); + self.add_edge_weight(a_idx, b_idx, w_b); + + self.recent_votes.push_front(vote); + while self.recent_votes.len() > 200 { + self.recent_votes.pop_back(); + } + } +} + +/// Compact rank-history entry stored per item in the ledger. +/// `caused_by` is resolved lazily at query time from `ingests_by_id`. +#[derive(Debug, Clone)] +pub struct RankHistoryEntry { + pub ts: i64, + pub scope_rank: usize, + pub scope_rank_delta: i32, + pub scope_total: usize, + pub global_rank: usize, + pub global_rank_delta: i32, + pub global_total: usize, + pub score: f64, + pub thread: String, + pub post_id: String, +} + +/// Invite link materialized from log events [`crate::events::InviteMinted`] / +/// [`crate::events::InviteRedeemed`] when those are replayed. +/// +/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM); +/// they are not appended to the JSONL log, so they disappear on restart. This struct is for +/// replay and any future persisted-mint path, not for the current ephemeral RPC mint. +#[derive(Debug, Clone)] +pub struct ActiveInviteState { + pub room_id: String, + pub capabilities: HashSet, + pub inviter: String, + pub uses_remaining: u32, + pub expires_ts_ms: Option, +} + +#[derive(Clone, Debug)] +pub enum RoomTimelineKind { + RoomCreated { + owner: String, + slug: String, + }, + RoomDeleted { + deleted_by: String, + }, + GrantAdded { + username: String, + granted_by: String, + capabilities: Vec, + }, + GrantRevoked { + username: String, + revoked_by: String, + capabilities: Vec, + }, +} + +#[derive(Clone, Debug)] +pub struct RoomTimelineEntry { + pub ts: i64, + pub kind: RoomTimelineKind, +} + +#[derive(Debug, Clone)] +#[derive(Default)] +pub struct ForumThreadState { + pub last_activity_ts: i64, + /// Username of the most recent person who bumped this thread. + pub last_actor: String, +} + + +#[derive(Debug, Clone, Default)] +pub struct ContentState { + pub ranking_group: GroupState, + pub items: HashSet, + pub item_bodies: HashMap, + /// Parent [`ItemId`] -> direct children. + pub item_children: HashMap>, + /// Per-item vote history (most recent first). + pub item_votes: HashMap>, + /// Per-item ingest references (most recent first). + pub item_snippets: HashMap>, + /// Item path -> threads that mention or vote on this item. + pub item_threads: HashMap>, + /// Per-item rank history, oldest first. + pub rank_history: HashMap>, +} + +#[derive(Debug, Clone)] +pub struct ReducerState { + pub content: HashMap, + + /// (provider, provider_id) -> username + pub users_by_provider: HashMap<(String, String), String>, + /// token_id -> (username, salt, token_hash) + pub tokens_by_id: HashMap, + pub agent_bindings: HashMap, + + + pub ingests_by_id: HashMap, + /// (scope, thread_tag) → ingest ids, newest first. + pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>, + /// Private room ids (`shortid/slug`) known from [`RoomCreated`]. + pub rooms: HashSet, + /// (scope, thread_tag) → last activity. + pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>, + pub actor_last_post_ts: HashMap, + + /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint. + pub ingests_ordered: Vec, + /// Username → ingest ids in post order (oldest first), for profile pages. + pub posts_by_actor: HashMap>, + /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI). + pub redacted_posts: HashSet, + /// Redaction event timestamp (ms) per post id. + pub post_redact_ts: HashMap, + /// room_id → username → capabilities + pub grants: HashMap>>, + /// room_id → chronological room admin lines (for thread UI). + pub room_timeline: HashMap>, + /// Invite token → active invite (absent when fully consumed or never minted). + pub invites: HashMap, +} + +impl ReducerState { + pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> { + self.content.get(scope) + } + + /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`). + pub fn try_thread_post_index_chronological( + &self, + scope: &ScopeId, + thread_tag: &str, + post_id: &str, + ) -> Option { + let tag = canonicalize_tag(thread_tag); + self.ingests_by_scope_thread + .get(&(scope.clone(), tag)) + .and_then(|q| q.iter().rev().position(|pid| pid == post_id)) + } + + pub fn thread_post_index_chronological( + &self, + scope: &ScopeId, + thread_tag: &str, + post_id: &str, + ) -> usize { + self.try_thread_post_index_chronological(scope, thread_tag, post_id) + .expect( + "post_id must appear in ingests_by_scope_thread for this scope and thread", + ) + } + + /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest. + pub fn posts_by_actor_ids(&self, actor: &str) -> Vec { + self.posts_by_actor + .get(actor) + .map(|q| q.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see. + pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec { + self.posts_by_actor_ids(actor) + .into_iter() + .filter(|id| { + !self.redacted_posts.contains(id) + && self.ingests_by_id.get(id).is_some_and(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + match &scope { + ScopeId::Public => true, + ScopeId::Room(rid) => { + viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View)) + } + } + }) + }) + .collect() + } + + pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool { + self.grants + .get(room_id) + .and_then(|t| t.get(username)) + .map(|caps| caps.contains(&cap)) + .unwrap_or(false) + } + + /// Invite link is present, not expired, and has uses left. + pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> { + let inv = self.invites.get(token)?; + if inv.uses_remaining == 0 { + return None; + } + if let Some(exp) = inv.expires_ts_ms { + if now_ms > exp { + return None; + } + } + Some(inv) + } + + pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState { + self.content.entry(scope).or_default() + } + + pub fn public(&self) -> &ContentState { + self.content.get(&ScopeId::Public).expect("public scope missing") + } + + /// Register parent→child edges for the full ancestor chain. + /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `""→a`. + /// Stops early when an intermediate is already registered (its ancestors must be too). + /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4 + fn add_child_edge(content: &mut ContentState, item: &ItemId) { + let mut child = item.clone(); + loop { + let Some(parent) = child.parent() else { break }; + let is_new = content + .item_children + .entry(parent.clone()) + .or_default() + .insert(child); + if !is_new { break; } + child = parent; + } + } + + /// Resolve an item path as a first-class [`ItemId`]. + fn normalize_item(item: &str) -> Option { + ItemId::parse(item) + } + + /// 1-indexed rank of `item` within its connected component in the parent scope. + /// 0 if the item has no votes connecting it to siblings (unranked). + fn scope_rank_of( + group: &GroupState, + item: &ItemId, + item_children: &HashMap>, + ) -> usize { + let scope = match item.parent() { + Some(p) => p, + None => return 0, + }; + let children = match item_children.get(&scope) { + None => return 0, + Some(c) => c, + }; + let &item_global_idx = match group.item_to_idx.get(item) { + None => return 0, + Some(i) => i, + }; + // Map scope children to compact local indices. + let sibling_idxs: Vec = children.iter() + .filter_map(|c| group.item_to_idx.get(c).copied()) + .collect(); + let global_to_local: HashMap = sibling_idxs.iter() + .enumerate().map(|(l, &g)| (g, l)).collect(); + let item_local = match global_to_local.get(&item_global_idx) { + None => return 0, + Some(&l) => l, + }; + // Connected components within scope. + let (comps, _) = crate::ranking::connected_components_from_voted_pairs( + sibling_idxs.len(), + group.voted_pairs.iter().filter_map(|(a, b)| { + Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?)) + }), + ); + // Find the component containing this item. + let comp_local = match comps.iter().find(|c| c.contains(&item_local)) { + None => return 0, + Some(c) => c, + }; + let comp_global: Vec = comp_local.iter() + .filter_map(|&l| sibling_idxs.get(l).copied()) + .collect(); + let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8); + ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0) + } + + /// 1-indexed position of `item` in the component-aware global flat list. + /// Components sorted largest-first; items ranked within each component. + /// 0 if the item is not in the ranking group. + fn global_rank_of(group: &GroupState, item: &ItemId) -> usize { + if !group.item_to_idx.contains_key(item) { + return 0; + } + let n = group.idx_to_item.len(); + let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs( + n, group.voted_pairs.iter().copied(), + ); + comps.sort_by_key(|b| std::cmp::Reverse(b.len())); + let mut pos = 1usize; + for comp in &comps { + let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8); + for r in &ranked { + if &r.item == item { + return pos; + } + pos += 1; + } + } + 0 + } + + /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history). + fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> { + let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?; + let canonical_thread = canonicalize_tag(&ing.thread_tag); + + let voted_items: Vec = doc + .statements + .iter() + .filter_map(|s| { + if let dsl::Stmt::Vote { item1, item2, .. } = s { + Some([item1, item2]) + } else { + None + } + }) + .flat_map(|pair| pair.into_iter()) + .filter_map(|raw| Self::normalize_item(raw)) + .collect::>() + .into_iter() + .collect(); + + let principal = ing.principal.clone(); + let delegate = ing.delegate.clone(); + + let before: HashMap = if !voted_items.is_empty() { + crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8); + voted_items + .iter() + .map(|it| { + ( + it.clone(), + ( + Self::scope_rank_of(&content.ranking_group, it, &content.item_children), + Self::global_rank_of(&content.ranking_group, it), + ), + ) + }) + .collect() + } else { + HashMap::new() + }; + + let mut ingest_items: HashSet = HashSet::new(); + + for stmt in doc.statements { + match stmt { + dsl::Stmt::Item { title, body } => { + let Some(item) = Self::normalize_item(&title) else { + continue; + }; + nav!(content.items, set_elem(item.clone())); + ingest_items.insert(item.clone()); + Self::add_child_edge(content, &item); + + if let Some(body_text) = body { + if !body_text.trim().is_empty() { + nav!(content.item_bodies, keypath(item.clone()), setval(body_text)); + } + } + } + dsl::Stmt::Vote { + item1, + item2, + ratio_left, + ratio_right, + explanation, + } => { + let Some(item_a) = Self::normalize_item(&item1) else { + continue; + }; + let Some(item_b) = Self::normalize_item(&item2) else { + continue; + }; + + let vote = VoteData { + ts: ing.ts, + a: item_a.clone(), + b: item_b.clone(), + ratio_left, + ratio_right, + body: explanation, + principal: principal.clone(), + delegate: delegate.clone(), + thread_tag: canonical_thread.clone(), + }; + + ingest_items.insert(item_a.clone()); + ingest_items.insert(item_b.clone()); + + nav!(content.items, set_elem(item_a.clone())); + nav!(content.items, set_elem(item_b.clone())); + Self::add_child_edge(content, &item_a); + Self::add_child_edge(content, &item_b); + + 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())); + } + } + dsl::Stmt::Prose { .. } => {} + } + } + + for item in ingest_items.iter() { + nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone())); + } + + for item in ingest_items.iter() { + nav!( + content.item_threads, + keypath(item.clone()), + set_elem(canonical_thread.clone()) + ); + } + + if !voted_items.is_empty() { + crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8); + let thread = canonical_thread.clone(); + for item in &voted_items { + let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children); + let after_global = Self::global_rank_of(&content.ranking_group, item); + let score = content + .ranking_group + .item_to_idx + .get(item) + .and_then(|&i| content.ranking_group.cached_scores.get(i)) + .copied() + .unwrap_or(0.0); + let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0)); + let prev = content.rank_history.get(item).and_then(|v| v.last()); + let scope_delta = if prev.is_none() { + 0 + } else { + after_scope as i32 - before_scope as i32 + }; + let global_delta = if prev.is_none() { + 0 + } else { + after_global as i32 - before_global as i32 + }; + let scope_total = item + .parent() + .and_then(|p| content.item_children.get(&p)) + .map(|s| s.len()) + .unwrap_or(0); + let global_total = content.ranking_group.idx_to_item.len(); + content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry { + ts: ing.ts, + scope_rank: after_scope, + scope_rank_delta: scope_delta, + scope_total, + global_rank: after_global, + global_rank_delta: global_delta, + global_total, + score, + thread: thread.clone(), + post_id: ing.id.clone(), + }); + } + } + + Ok(()) + } + + fn rebuild_scope_content(&mut self, scope: ScopeId) { + let mut cs = ContentState::default(); + for id in &self.ingests_ordered { + if self.redacted_posts.contains(id) { + continue; + } + let Some(ing) = self.ingests_by_id.get(id) else { + continue; + }; + if scope_from_room_wire(&ing.room_id) != scope { + continue; + } + let _ = Self::apply_ingest_to_content(&mut cs, ing); + } + self.content.insert(scope, cs); + // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so + // per-post URLs and chronological indices remain stable; only projected garden state resets. + } + + /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants). + fn purge_private_room(&mut self, room_id: &str) { + let scope = ScopeId::Room(room_id.to_string()); + self.rooms.remove(room_id); + self.grants.remove(room_id); + self.room_timeline.remove(room_id); + self.invites.retain(|_, inv| inv.room_id != room_id); + self.content.remove(&scope); + self.forum_threads.retain(|(s, _), _| s != &scope); + self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope); + + let mut to_drop: Vec = self + .ingests_by_id + .iter() + .filter(|(_, ing)| ing.room_id.trim() == room_id) + .map(|(id, _)| id.clone()) + .collect(); + to_drop.sort(); + to_drop.dedup(); + for id in to_drop { + if let Some(ing) = self.ingests_by_id.remove(&id) { + self.posts_by_actor + .entry(ing.principal) + .and_modify(|q| { + q.retain(|x| x != &id); + }); + } + self.ingests_ordered.retain(|x| x != &id); + self.redacted_posts.remove(&id); + self.post_redact_ts.remove(&id); + } + } + + pub fn apply_event(&mut self, event: Event) { + match event { + Event::UserRegistered(ur) => { + self.users_by_provider.insert( + (ur.provider.to_lowercase(), ur.provider_id.clone()), + ur.username, + ); + } + Event::TokenIssued(ti) => { + self.tokens_by_id.insert( + ti.token_id.clone(), + (ti.username, ti.salt.clone(), ti.token_hash.clone()), + ); + } + Event::AgentBound(ab) => { + if ab.agent.is_empty() { + return; + } + self.agent_bindings.insert(ab.agent, ab.username); + } + Event::RoomCreated(rc) => { + self.rooms.insert(rc.room_id.clone()); + self.room_timeline + .entry(rc.room_id.clone()) + .or_default() + .push(RoomTimelineEntry { + ts: rc.ts, + kind: RoomTimelineKind::RoomCreated { + owner: rc.owner.clone(), + slug: rc.slug.clone(), + }, + }); + } + Event::RoomDeleted(rd) => { + let room_id = rd.room_id.clone(); + if self.rooms.contains(&room_id) { + self.room_timeline + .entry(room_id.clone()) + .or_default() + .push(RoomTimelineEntry { + ts: rd.ts, + kind: RoomTimelineKind::RoomDeleted { + deleted_by: rd.deleted_by.clone(), + }, + }); + } + self.purge_private_room(&room_id); + } + Event::Ingest(mut ing) => { + ing.thread_tag = canonicalize_tag(&ing.thread_tag); + let room_key = ing.room_id.trim().to_string(); + let scope = scope_from_room_wire(&room_key); + let canonical_thread = ing.thread_tag.clone(); + let scope_thread_key = (scope.clone(), canonical_thread.clone()); + + { + let content = self.content_for_scope_mut(scope.clone()); + if Self::apply_ingest_to_content(content, &ing).is_err() { + eprintln!( + "WARNING: Skipping malformed ingest event {}: parse failed", + ing.id + ); + return; + } + } + + self.ingests_by_id.insert(ing.id.clone(), ing.clone()); + + let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default(); + let prev_ts = ft.last_activity_ts; + if ing.ts > prev_ts { + ft.last_activity_ts = ing.ts; + ft.last_actor = ing.principal.clone(); + } + + nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone())); + + nav!(self.ingests_ordered, push_back(ing.id.clone())); + + nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone())); + + nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts)); + } + Event::PostRedacted(pr) => { + self.redacted_posts.insert(pr.post_id.clone()); + self.post_redact_ts.insert(pr.post_id.clone(), pr.ts); + let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else { + return; + }; + let scope = scope_from_room_wire(ing.room_id.trim()); + // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`. + self.rebuild_scope_content(scope); + } + Event::GrantAdded(ga) => { + let room_id = ga.room_id.clone(); + let caps = self.grants + .entry(ga.room_id) + .or_default() + .entry(ga.username.clone()) + .or_default(); + for cap in ga.capabilities.iter().copied() { + caps.insert(cap); + } + self.room_timeline + .entry(room_id) + .or_default() + .push(RoomTimelineEntry { + ts: ga.ts, + kind: RoomTimelineKind::GrantAdded { + username: ga.username.clone(), + granted_by: ga.granted_by.clone(), + capabilities: ga.capabilities.clone(), + }, + }); + } + Event::GrantRevoked(gr) => { + let room_id = gr.room_id.clone(); + if let Some(room_grants) = self.grants.get_mut(&gr.room_id) { + let username = gr.username.clone(); + if let Some(caps) = room_grants.get_mut(&username) { + for cap in &gr.capabilities { + caps.remove(cap); + } + if caps.is_empty() { + room_grants.remove(&username); + } + } + if room_grants.is_empty() { + self.grants.remove(&gr.room_id); + } + } + self.room_timeline + .entry(room_id) + .or_default() + .push(RoomTimelineEntry { + ts: gr.ts, + kind: RoomTimelineKind::GrantRevoked { + username: gr.username.clone(), + revoked_by: gr.revoked_by.clone(), + capabilities: gr.capabilities.clone(), + }, + }); + } + Event::InviteMinted(im) => { + self.invites.insert( + im.token.clone(), + ActiveInviteState { + room_id: im.room_id.clone(), + capabilities: im.capabilities.iter().copied().collect(), + inviter: im.inviter.clone(), + uses_remaining: im.max_uses, + expires_ts_ms: im.expires_ts_ms, + }, + ); + } + Event::InviteRedeemed(ir) => { + if let Some(inv) = self.invites.get_mut(&ir.token) { + inv.uses_remaining = inv.uses_remaining.saturating_sub(1); + if inv.uses_remaining == 0 { + self.invites.remove(&ir.token); + } + } + } + } + } +} + +impl Default for ReducerState { + fn default() -> Self { + let mut content = HashMap::new(); + content.insert(ScopeId::Public, ContentState::default()); + Self { + content, + users_by_provider: HashMap::new(), + tokens_by_id: HashMap::new(), + agent_bindings: HashMap::new(), + ingests_by_id: HashMap::new(), + ingests_by_scope_thread: HashMap::new(), + rooms: HashSet::new(), + forum_threads: HashMap::new(), + actor_last_post_ts: HashMap::new(), + ingests_ordered: Vec::new(), + posts_by_actor: HashMap::new(), + redacted_posts: HashSet::new(), + post_redact_ts: HashMap::new(), + grants: HashMap::new(), + room_timeline: HashMap::new(), + invites: HashMap::new(), + } + } +} + diff --git a/seed.tdsl b/seed.tdsl new file mode 100644 index 0000000000000000000000000000000000000000..499a615041e3be5987dcb306ede84f4ce936e0be --- /dev/null +++ b/seed.tdsl @@ -0,0 +1,21 @@ +reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social? +jsonl based. Same stack as slug +fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval +Viewcounters for every site +Login with Reddit oauth? +Streaks? Streak gates? Progressive revelation? +One pair of the day +Use attributes again, for assholery. To distinguish from reddits single dimension +r/sorter_south_park_characters +Is a one shot category that only has the characters. +subs starting with sorter have special priority in sorter index. + +layer above reddit? + + +use websocket based bar i had built in afterlife... + + +claude {{ +What I'd push on hardest: what's the artifact that gets shared? Letterboxd works because every movie has a beautiful URL, every user has a beautiful profile, every list is screenshot-worthy. AITA-but-ranked needs the same. "This person ranks 97th percentile of asshole, beating 4,891 other Reddit posters this month" — presented well, that's a tweet, that's a screenshot, that's how the next thousand users find you. Build the share artifact early. Streaks and progressive revelation are for retention; the share-out is for acquisition; you need both but the acquisition piece is what makes streaks worth designing. +}} diff --git a/ui_action.rs b/ui_action.rs new file mode 100644 index 0000000000000000000000000000000000000000..5e4131dd346a6f80bfe091a9b0cf7902721bc47f --- /dev/null +++ b/ui_action.rs @@ -0,0 +1,270 @@ +//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]). +//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API). + +use crate::form_template::fill_template_from_form; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use thiserror::Error; + +/// Form field name for the compact JSON template (possibly with `{"$form":"…"}` holes). +pub const UI_RPC_FIELD: &str = "__rpc__"; + +fn default_ui_form_action() -> String { + "/ui".to_string() +} + +/// HTML form / fetch `POST /ui` payload after template fill and deserialization. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum HtmlUiAction { + /// Forum ingest via `POST /ui`. + PostIngest { + room: String, + thread_tag: String, + text: String, + #[serde(default)] + error_target: Option, + #[serde(default)] + form_id: Option, + }, + /// DSL check / validation via `POST /ui`. + CheckIngest { + room: String, + thread_tag: String, + text: String, + #[serde(default)] + error_target: Option, + #[serde(default)] + form_id: Option, + }, + /// Post a pairwise vote from `/vote` (browser compose). + VoteComparePost { + room: String, + thread_tag: String, + left_item: String, + right_item: String, + /// From form fields (string); parsed server-side. + ratio_left: String, + ratio_right: String, + explanation: String, + /// Same-origin path after successful post (JS redirect). + next: String, + /// Pool parent item path, if the vote was initiated from a pool URL. + #[serde(default)] + pool: Option, + #[serde(default = "default_ui_form_action")] + form_action: String, + }, + /// Set or clear the garden HUD pin cookie (`slug_garden_pin`). Response is **`303` + `Set-Cookie`** when submitted as a full-navigation form (`data-navigate="full"`), matching `POST /theme`. + SetGardenPin { + #[serde(default)] + clear: bool, + #[serde(default)] + room_wire: String, + #[serde(default)] + item_storage: Option, + next: String, + /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path. + #[serde(default = "default_ui_form_action")] + form_action: String, + }, + /// Resolve children or siblings for an external garden item. + ResolveExternal { + room_wire: String, + item_storage: String, + mode: String, + next: String, + #[serde(default = "default_ui_form_action")] + form_action: String, + }, + /// Author redacts own post via `POST /ui`. + RedactPost { post_id: String }, + /// Morph `#room-members-section` — members list open or collapsed (server-rendered). + SetRoomMembersExpanded { + room_wire: String, + #[serde(default)] + expanded: bool, + }, + /// Delete the private room (Manage only); redirects to `/` on success. + DeleteRoom { room: String }, + /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home). + SetNewThreadComposeExpanded { + room_wire: String, + #[serde(default)] + expanded: bool, + }, + /// Replace a truncated post card with the full body (same thread index). + ExpandPostFull { + room: String, + thread_tag: String, + post_index: usize, + }, + /// Expand a redacted/tombstone post to show stripped body (author view). + ExpandRedactedPost { + room: String, + thread_tag: String, + post_index: usize, + }, + /// Collapse an expanded redacted post back to the tombstone card. + CollapseRedactedPost { + room: String, + thread_tag: String, + post_index: usize, + }, + /// Copy full thread text (CLI `forum show` format) to the clipboard. + CopyThread { + room: String, + thread_tag: String, + copy_btn_id: String, + }, +} + +#[derive(Debug, Error)] +pub enum HtmlUiParseError { + #[error("missing __rpc__ field")] + MissingRpc, + #[error("invalid template json: {0}")] + Template(serde_json::Error), + #[error("invalid ui action: {0}")] + Action(serde_json::Error), +} + +/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize. +pub fn parse_html_ui_from_form( + form: &HashMap, +) -> Result { + let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?; + let mut hole_map = form.clone(); + hole_map.remove(UI_RPC_FIELD); + let v: Value = + fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?; + serde_json::from_value(v).map_err(HtmlUiParseError::Action) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_post_ingest_with_holes() { + let template = serde_json::json!({ + "action": "post_ingest", + "room": "public", + "thread_tag": {"$form": "thread_tag"}, + "text": {"$form": "text"}, + "error_target": "e", + "form_id": "f", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + form.insert("thread_tag".into(), "x".into()); + form.insert("text".into(), "body".into()); + + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::PostIngest { + room: "public".into(), + thread_tag: "x".into(), + text: "body".into(), + error_target: Some("e".into()), + form_id: Some("f".into()), + } + ); + } + + #[test] + fn expand_post_full_round_trip() { + let template = serde_json::json!({ + "action": "expand_post_full", + "room": "public", + "thread_tag": "demo", + "post_index": 3, + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::ExpandPostFull { + room: "public".into(), + thread_tag: "demo".into(), + post_index: 3, + } + ); + } + + #[test] + fn set_room_members_expanded_defaults_false() { + let template = serde_json::json!({ + "action": "set_room_members_expanded", + "room_wire": "ab/cd", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::SetRoomMembersExpanded { + room_wire: "ab/cd".into(), + expanded: false, + } + ); + } + + #[test] + fn copy_thread_round_trip() { + let template = serde_json::json!({ + "action": "copy_thread", + "room": "public", + "thread_tag": "demo", + "copy_btn_id": "thread-copy-top", + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::CopyThread { + room: "public".into(), + thread_tag: "demo".into(), + copy_btn_id: "thread-copy-top".into(), + } + ); + } + + #[test] + fn set_new_thread_compose_expanded_true() { + let template = serde_json::json!({ + "action": "set_new_thread_compose_expanded", + "room_wire": "ab/cd", + "expanded": true, + }); + let mut form = HashMap::new(); + form.insert( + UI_RPC_FIELD.to_string(), + serde_json::to_string(&template).unwrap(), + ); + let a = parse_html_ui_from_form(&form).unwrap(); + assert_eq!( + a, + HtmlUiAction::SetNewThreadComposeExpanded { + room_wire: "ab/cd".into(), + expanded: true, + } + ); + } +} diff --git a/vote.rs b/vote.rs new file mode 100644 index 0000000000000000000000000000000000000000..d0ec78cd675eae284d056fb3b8eaf5cc853d6263 --- /dev/null +++ b/vote.rs @@ -0,0 +1,580 @@ +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode, Uri}, + response::{Html, IntoResponse}, +}; +use axum_extra::extract::cookie::CookieJar; +use maud::html; +use serde::Deserialize; +use serde_json::json; +use std::collections::{HashMap, HashSet}; + +use crate::{ + api::optional_principal, + canonical_path::canonicalize_tag, + form_template::template_json_compact, + html::{ + forum::ThreadNav, + layout_full_bleed_chromeless, + ratio_pct, render_item_body_in_scope, + theme_from_jar, theme_next_from_uri, + user_can_post_room, + ui_action::UI_RPC_FIELD, + JsBuilder, + }, + middleware::canonical_view_url, + path_types::ItemId, + reducer::{ContentState, ScopeId}, + scope_rank::suggest_next_pair_in_pool, + state::AppState, +}; + +use super::{ + access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room}, + item::{item_display_path, login_href_with_next}, +}; + +fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String { + let cands: HashSet = content + .item_threads + .get(a) + .into_iter() + .chain(content.item_threads.get(b)) + .flat_map(|s| s.iter().cloned()) + .collect(); + if cands.is_empty() { + return "vote".to_string(); + } + let mut v: Vec = cands.into_iter().collect(); + v.sort(); + canonicalize_tag(&v[0]) +} + +/// Canonical unordered pair: lexicographic by storage string (stable edge identity). +pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) { + let ac = a.clone().normalized_storage(); + let bc = b.clone().normalized_storage(); + if ac.as_str() <= bc.as_str() { + (ac, bc) + } else { + (bc, ac) + } +} + +/// All votes whose endpoints are exactly this unordered pair (unsorted). +pub(super) fn edge_vote_entries_for_pair( + content: &ContentState, + a: &ItemId, + b: &ItemId, +) -> Vec { + let (lo, hi) = canonical_edge_items(a, b); + let lo_s = lo.as_str(); + let hi_s = hi.as_str(); + content + .item_votes + .get(&lo) + .into_iter() + .flat_map(|q| q.iter()) + .filter(|v| { + (v.a.as_str() == lo_s && v.b.as_str() == hi_s) + || (v.a.as_str() == hi_s && v.b.as_str() == lo_s) + }) + .cloned() + .collect() +} + +pub(super) fn ratios_for_compare_page( + v: &crate::reducer::VoteData, + page_left: &ItemId, + page_right: &ItemId, +) -> (i32, i32) { + let pl = page_left.as_str(); + let pr = page_right.as_str(); + match (v.a.as_str(), v.b.as_str()) { + (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right), + (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left), + _ => (v.ratio_left, v.ratio_right), + } +} + +fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 { + let l = ratio_left.max(0) as f64; + let r = ratio_right.max(0) as f64; + let sum = l + r; + if sum <= 0.0 { + 0.5 + } else { + l / sum + } +} + +/// Stronger preference for **`page_left` first**; ties **newer first**. +pub(super) fn sort_votes_for_compare_display( + mut votes: Vec, + page_left: &ItemId, + page_right: &ItemId, +) -> Vec { + votes.sort_by(|va, vb| { + let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right); + let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right); + let sa = left_share_normalized(ratio_left_a, ratio_right_a); + let sb = left_share_normalized(ratio_left_b, ratio_right_b); + match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) { + std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts), + o => o, + } + }); + votes +} + +/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking). +pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize { + let (lo, hi) = canonical_edge_items(a, b); + let lo_s = lo.as_str(); + let hi_s = hi.as_str(); + content + .item_votes + .get(&lo) + .into_iter() + .flat_map(|q| q.iter()) + .filter(|v| { + (v.a.as_str() == lo_s && v.b.as_str() == hi_s) + || (v.a.as_str() == hi_s && v.b.as_str() == lo_s) + }) + .count() +} + +fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec { + let set: HashSet = content + .item_threads + .get(a) + .into_iter() + .chain(content.item_threads.get(b)) + .flat_map(|s| s.iter().cloned()) + .collect(); + let mut v: Vec = set.into_iter().collect(); + v.sort(); + v.into_iter().map(|t| canonicalize_tag(&t)).collect() +} + +fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup { + let votes = edge_vote_entries_for_pair(content, left, right); + let votes = sort_votes_for_compare_display(votes, left, right); + let legend_left = item_display_path(left.as_str()); + let legend_right = item_display_path(right.as_str()); + html! { + @if votes.is_empty() { + p class="muted vote-edge-empty" { "no votes on this pair in this scope yet" } + } @else { + h3 class="vote-edge-history-title" { + "votes on this edge" + span class="vote-edge-history-axis muted" { " · " (legend_left) " : " (legend_right) } + } + ul class="vote-edge-history" { + @for v in &votes { + @let (r_left, r_right) = ratios_for_compare_page(v, left, right); + @let pct = ratio_pct(r_left, r_right); + @let row_tip = format!( + "{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}", + r_left, + r_right, + legend_left, + legend_right, + v.thread_tag, + v.principal, + ); + li class="vote-edge-history-row" title=(row_tip) { + div class="vote-edge-meta" { + span class="vote-edge-ratio" { (format!("{}:{}", r_left, r_right)) } + span class="muted" { " · #" (v.thread_tag) " · @" (v.principal) } + } + div class="ratio-bar vote-edge-bar" aria-hidden="true" { + div class="ratio-left" style={(format!("width: {:.3}%;", pct))} {} + div class="ratio-right" style={(format!("width: {:.3}%;", 100.0 - pct))} {} + } + @if !v.body.trim().is_empty() { + div class="vote-edge-reason muted" { (v.body.trim()) } + } + } + } + } + } + } +} + +/// After a successful vote post: refresh edge history (no in-page preview card). +#[allow(clippy::too_many_arguments)] +pub(crate) async fn vote_compare_post_success_js( + state: &AppState, + nav: &ThreadNav, + _room_wire: &str, + _thread_tag: &str, + left: &ItemId, + right: &ItemId, + pool: Option<&ItemId>, + _post_id: &str, + _post_idx: Option, +) -> String { + let reduced = state.reduced.read().await; + let content = content_for_garden_view(&reduced, &nav.scope()); + let edge_history = vote_edge_history_markup(content, left, right); + let next_pair = suggest_next_vote_pair(content, left, right, pool); + let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool); + drop(reduced); + JsBuilder::new() + .morph_inner_selector("#vote-edge-history-region", edge_history) + .morph_selector(".vote-compare-nav", nav_markup) + .build() +} + +pub(super) fn vote_compare_href( + nav: &ThreadNav, + left: &ItemId, + right: &ItemId, + thread_override: Option<&str>, + pool: Option<&ItemId>, +) -> String { + let left_dp = left.display_path(); + let right_dp = right.display_path(); + let left_q = urlencoding::encode(&left_dp); + let right_q = urlencoding::encode(&right_dp); + let mut base = format!( + "{}/vote?left={}&right={}", + nav.room_path_prefix_for_vote_compare(), + left_q, + right_q + ); + if let Some(t) = thread_override.filter(|s| !s.is_empty()) { + base = format!("{}&thread={}", base, urlencoding::encode(t)); + } + if let Some(p) = pool { + let pool_dp = p.display_path(); + base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp)); + } + base +} + +pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String { + let display = ItemId::parse(pool_item_str) + .map(|i| i.display_path()) + .unwrap_or_else(|| pool_item_str.to_string()); + format!( + "{}/vote?pool={}", + nav.room_path_prefix_for_vote_compare(), + urlencoding::encode(&display) + ) +} + +fn vote_compare_nav_markup( + nav: &ThreadNav, + next_pair: Option<&(ItemId, ItemId)>, + pool: Option<&ItemId>, +) -> maud::Markup { + let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool)); + html! { + div class="vote-compare-nav" { + @if let Some(href) = &next_pair_href { + a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" } + } @else { + span class="vote-compare-next is-disabled" { "no next pair" } + } + } + } +} + +pub(super) fn suggest_next_vote_pair( + content: &ContentState, + current_left: &ItemId, + current_right: &ItemId, + pool_parent: Option<&ItemId>, +) -> Option<(ItemId, ItemId)> { + let pool: Vec = if let Some(parent) = pool_parent { + content + .item_children + .get(parent) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default() + } else if current_left.parent().as_ref().map(|p| p.as_str()) + == current_right.parent().as_ref().map(|p| p.as_str()) + { + current_left + .parent() + .and_then(|parent| { + content + .item_children + .get(&parent.normalized_storage()) + .cloned() + }) + .map(|children| children.into_iter().collect()) + .unwrap_or_default() + } else { + Vec::new() + }; + if pool.len() < 2 { + return None; + } + suggest_next_pair_in_pool( + &content.ranking_group, + &pool, + Some((current_left, current_right)), + ) +} + +pub(super) fn vote_compare_item_card( + nav: &ThreadNav, + item: &ItemId, + body: Option<&String>, + side_class: &str, + item_bodies: Option<&HashMap>, +) -> maud::Markup { + html! { + div class=(format!("vote-compare-side {side_class}")) { + a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) { + code { (item_display_path(item.as_str())) } + } + @if let Some(body) = body.filter(|b| !b.trim().is_empty()) { + div class="vote-compare-item-body" { + (render_item_body_in_scope( + body, + nav.garden_root_url(), + item_bodies, + )) + } + } @else { + p class="muted vote-compare-item-body-empty" { "no body yet" } + } + } + } +} +#[derive(Debug, Deserialize)] +pub struct VoteCompareQuery { + #[serde(default)] + pub left: Option, + #[serde(default)] + pub right: Option, + #[serde(default)] + pub thread: Option, + #[serde(default)] + pub pool: Option, +} + +/// Public pairwise vote UI — `/vote?left=&right=&thread=`. +pub async fn vote_compare_page( + State(state): State, + Query(q): Query, + headers: HeaderMap, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { + let nav = ThreadNav::public(); + vote_compare_inner(state, q, nav, headers, jar, uri).await +} + +pub async fn room_vote_compare_page( + State(state): State, + Path(room_key): Path, + Query(q): Query, + headers: HeaderMap, + jar: CookieJar, + uri: Uri, +) -> impl IntoResponse { + let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; + let Some(nav) = ThreadNav::from_room_id(&room_id) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; + let reduced = state.reduced.read().await; + let user = optional_principal(&headers, &jar, &reduced); + if !user_can_view_room(&reduced, &room_id, user.as_deref()) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } + if !room_scope_has_garden_content(&reduced, &nav) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } + drop(reduced); + vote_compare_inner(state, q, nav, headers, jar, uri).await +} + +async fn vote_compare_inner( + state: AppState, + q: VoteCompareQuery, + nav: ThreadNav, + headers: HeaderMap, + jar: CookieJar, + uri: Uri, +) -> axum::response::Response { + let pool_id: Option = match q.pool.as_deref() { + Some(p) => match ItemId::parse(p.trim()) { + Some(i) => Some(i.normalized_storage()), + None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(), + }, + None => None, + }; + + let (left, right) = match (q.left.as_deref(), q.right.as_deref()) { + (Some(l), Some(r)) => { + let left = match ItemId::parse(l.trim()) { + Some(i) => i.normalized_storage(), + None => return (StatusCode::NOT_FOUND, "bad left item").into_response(), + }; + let right = match ItemId::parse(r.trim()) { + Some(i) => i.normalized_storage(), + None => return (StatusCode::NOT_FOUND, "bad right item").into_response(), + }; + if left == right { + return (StatusCode::BAD_REQUEST, "items must differ").into_response(); + } + (left, right) + } + (None, None) => { + let Some(pool) = pool_id.as_ref() else { + return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response(); + }; + let reduced = state.reduced.read().await; + let content = content_for_garden_view(&reduced, &nav.scope()); + let children: Vec = content + .item_children + .get(pool) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + if children.len() < 2 { + drop(reduced); + return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response(); + } + let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None); + drop(reduced); + match pair { + Some(p) => p, + None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(), + } + } + _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(), + }; + + let reduced = state.reduced.read().await; + let content = content_for_garden_view(&reduced, &nav.scope()); + let viewer = optional_principal(&headers, &jar, &reduced); + let can_post = match &nav.scope() { + ScopeId::Public => viewer.is_some(), + ScopeId::Room(rid) => viewer + .as_ref() + .map(|u| user_can_post_room(&reduced, rid, u)) + .unwrap_or(false), + }; + let auto_thread = q + .thread + .as_ref() + .map(|t| canonicalize_tag(t)) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right)); + let thread_tags = vote_thread_tags_for_pair(content, &left, &right); + let edge_history = vote_edge_history_markup(content, &left, &right); + let left_body = content.item_bodies.get(&left).cloned(); + let right_body = content.item_bodies.get(&right).cloned(); + let item_bodies_for_cards = content.item_bodies.clone(); + let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref()); + drop(reduced); + + let title = format!( + "vote — {} vs {}", + item_display_path(left.as_str()), + item_display_path(right.as_str()) + ); + let next_path = uri + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_else(|| "/vote".into()); + + let rpc_json = template_json_compact(&json!({ + "action": "vote_compare_post", + "room": nav.room_wire, + "thread_tag": {"$form": "thread_tag"}, + "left_item": left.as_str(), + "right_item": right.as_str(), + "ratio_left": {"$form": "ratio_left"}, + "ratio_right": {"$form": "ratio_right"}, + "explanation": {"$form": "explanation"}, + "next": next_path, + "pool": pool_id.as_ref().map(|p| p.as_str()), + "form_action": "/ui", + })) + .expect("vote compare rpc json"); + + let body = html! { + section class="vote-compare-shell" { + h2 { "compare" } + div class="vote-compare-pair" { + (vote_compare_item_card( + &nav, + &left, + left_body.as_ref(), + "vote-compare-left", + Some(&item_bodies_for_cards), + )) + span class="vote-compare-vs" { "vs" } + (vote_compare_item_card( + &nav, + &right, + right_body.as_ref(), + "vote-compare-right", + Some(&item_bodies_for_cards), + )) + } + (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref())) + div id="vote-edge-history-region" { + (edge_history) + } + @if can_post { + form id="vote-compare-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json); + div class="vote-thread-picker" { + label class="vote-thread-picker-label" { "thread" } + select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" { + @if thread_tags.is_empty() { + option value="vote" selected { "#vote" } + } + @for t in &thread_tags { + @if *t == auto_thread { + option value=(t) selected { "#" (t) } + } @else { + option value=(t) { "#" (t) } + } + } + } + } + input type="hidden" name="ratio_left" id="vote-ratio-left" value="50"; + input type="hidden" name="ratio_right" id="vote-ratio-right" value="50"; + label class="vote-compare-slider-label" { + span id="vote-slider-left-label" { (item_display_path(left.as_str())) } + input type="range" id="vote-preference-slider" min="0" max="100" value="50" + aria-valuemin="0" aria-valuemax="100"; + span id="vote-slider-right-label" { (item_display_path(right.as_str())) } + } + label class="vote-explain-label" { "reason (required)" } + textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {} + div id="vote-compare-errors" {} + p { button type="submit" { "post vote" } } + } + } @else { + p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." } + } + } + }; + + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + + let page = layout_full_bleed_chromeless( + &title, + "view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen", + body, + Some(view_count), + theme_from_jar(&jar), + &theme_next_from_uri(&uri), + ); + Html(page.into_string()).into_response() +} +