{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[1d14ff09] Ephemeral Reddit content; log structure only (#46)\n\n* Keep Reddit content ephemeral; log structure only\n\nRemove EntityImported and EntityStore. Reddit fetches write display\ncontent directly to the projection with a fetched_at timestamp, while\nthe event log records NodeEnsured for discovered identities only.\n\nA background task evicts cached display content after 48 hours. Votes,\ntree structure, and ItemIds remain in the log and projection.\n\nCo-authored-by: tommy \n\n* Fix reddit import test assertions and Clojure syntax\n\nCo-authored-by: tommy \n\n---------\n\nCo-authored-by: Cursor Agent \n\nSide A — unified diff (full patch):\ndiff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs\nindex 4762d0d23bc8e2df439afe09a3235ba7f72bd486..3d988416ad36b27a7d3dc84280cfdbdcafa43e69 100644\n--- a/server/src/bin/storage_bench.rs\n+++ b/server/src/bin/storage_bench.rs\n@@ -6,8 +6,8 @@ use std::{\n };\n \n use sorter2_server::{\n- entity_store::EntityStore, event_log::EventLog, events::Event, journal::JournalClient,\n- projection_apply, projection_store::ProjectionStore,\n+ event_log::EventLog, events::Event, journal::JournalClient, projection_apply,\n+ projection_store::ProjectionStore,\n };\n \n #[tokio::main]\n@@ -18,12 +18,10 @@ async fn main() -> Result<(), Box> {\n let data_dir = opts.data_dir.to_string_lossy().into_owned();\n let event_log = Arc::new(EventLog::new(format!(\"{data_dir}/events.jsonl\")));\n let db = durable::Db::open(opts.data_dir.join(\"store\"))?;\n- let entity_store = EntityStore::from_db(&db)?;\n let projection_store = ProjectionStore::from_db(&db)?;\n \n let journal = JournalClient::spawn(\n event_log.clone(),\n- entity_store.clone(),\n projection_store.clone(),\n event_log.last_sequence().await? + 1,\n );\n@@ -46,12 +44,9 @@ async fn main() -> Result<(), Box> {\n drop(journal);\n \n let rebuild_start = Instant::now();\n- entity_store.reset()?;\n projection_store.reset()?;\n let rebuild = event_log\n- .replay(|record| {\n- projection_apply::apply_records(&projection_store, &entity_store, &[record])\n- })\n+ .replay(|record| projection_apply::apply_records(&projection_store, &[record]))\n .await?;\n let rebuild_elapsed = rebuild_start.elapsed();\n \ndiff --git a/server/src/entity_store.rs b/server/src/entity_store.rs\ndeleted file mode 100644\nindex d29f39aecca6f12cdcf263cf77c3654eb4ee6cfa..0000000000000000000000000000000000000000\n--- a/server/src/entity_store.rs\n+++ /dev/null\n@@ -1,134 +0,0 @@\n-//! Off-heap storage for full entity payloads (Reddit API JSON).\n-//!\n-//! Derived [`crate::reducer::EntityData`] is stored on the node; the raw JSON\n-//! lives here, in the shared durable [`Store`] schema.\n-\n-use std::path::Path;\n-\n-use durable::{Batch, Db, Durability};\n-use serde_json::Value;\n-\n-use crate::{\n- path_types::ItemId,\n- storage_dto::{decode_entity_payload, encode_entity_payload},\n- storage_schema::{Store, StoreFields},\n-};\n-\n-const ENTITY_SCHEMA_KEY: &str = \"schema_version\";\n-const ENTITY_SCHEMA_VERSION: u64 = 2;\n-\n-#[derive(Debug, thiserror::Error)]\n-pub enum EntityStoreError {\n- #[error(\"durable error: {0}\")]\n- Durable(#[from] durable::Error),\n- #[error(\"json error: {0}\")]\n- Json(#[from] serde_json::Error),\n- #[error(\"storage decode error: {0}\")]\n- Storage(String),\n- #[error(\"io error: {0}\")]\n- Io(#[from] std::io::Error),\n-}\n-\n-/// Disk-backed map of entity id → raw JSON payload.\n-#[derive(Clone)]\n-pub struct EntityStore {\n- db: Db,\n-}\n-\n-impl EntityStore {\n- /// Open (or create) the entity database under `dir`.\n- pub fn open(dir: &Path) -> Result {\n- std::fs::create_dir_all(dir)?;\n- let db = Db::open(dir)?;\n- Self::from_db(&db)\n- }\n-\n- /// Create an entity store backed by an already-open database.\n- pub fn from_db(db: &Db) -> Result {\n- let store = Self { db: db.clone() };\n- let version = Store::root()\n- .entity_meta()\n- .key(&ENTITY_SCHEMA_KEY.to_string())\n- .get(db)?;\n- if version != Some(ENTITY_SCHEMA_VERSION) {\n- store.reset()?;\n- }\n- Ok(store)\n- }\n-\n- /// Clear rebuildable entity payloads and reset storage schema metadata.\n- pub fn reset(&self) -> Result<(), EntityStoreError> {\n- let root = Store::root();\n- self.db.apply(\n- &[root.entities().clear(), root.entity_meta().clear()],\n- Durability::SyncWal,\n- )?;\n- self.db.run(\n- root.entity_meta()\n- .key(&ENTITY_SCHEMA_KEY.to_string())\n- .set(&ENTITY_SCHEMA_VERSION),\n- Durability::SyncWal,\n- )?;\n- Ok(())\n- }\n-\n- /// Persist a payload for `id` (overwrites any existing entry).\n- pub fn put(&self, id: &ItemId, payload: &Value) -> Result<(), EntityStoreError> {\n- self.db.run(\n- Store::root()\n- .entities()\n- .key(&id.as_str().to_string())\n- .set(&encode_entity_payload(payload)),\n- Durability::SyncWal,\n- )?;\n- Ok(())\n- }\n-\n- /// Add a payload write to the caller's batch.\n- pub fn put_in_batch(\n- &self,\n- batch: &mut Batch,\n- id: &ItemId,\n- payload: &Value,\n- ) -> Result<(), EntityStoreError> {\n- batch.write(\n- Store::root()\n- .entities()\n- .key(&id.as_str().to_string())\n- .set(&encode_entity_payload(payload)),\n- );\n- Ok(())\n- }\n-\n- /// Load a stored payload, if present.\n- pub fn get(&self, id: &ItemId) -> Result, EntityStoreError> {\n- match Store::root()\n- .entities()\n- .key(&id.as_str().to_string())\n- .get(&self.db)?\n- {\n- Some(record) => decode_entity_payload(record)\n- .map(Some)\n- .map_err(EntityStoreError::Storage),\n- None => Ok(None),\n- }\n- }\n-}\n-\n-#[cfg(test)]\n-mod tests {\n- use super::*;\n- use serde_json::json;\n-\n- #[test]\n- fn round_trip_payload() {\n- let tmp = tempfile::tempdir().unwrap();\n- let store = EntityStore::open(tmp.path()).unwrap();\n- let id = ItemId::from_url(\"https://reddit.com/r/rust\").unwrap();\n- let payload = json!({\"kind\": \"t5\", \"data\": {\"display_name\": \"rust\"}});\n-\n- store.put(&id, &payload).unwrap();\n- let loaded = store.get(&id).unwrap().unwrap();\n- assert_eq!(loaded, payload);\n- }\n-}\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex a3d88e285c645a96d33e6f0aed1b487b843aceb8..d76c3bb4277216b0d39c9422ba7a50db10a95e05 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,5 +1,4 @@\n use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n \n /// Schema version for JSONL log records. Bump when event semantics change.\n pub const CURRENT_LOG_SCHEMA: u32 = 1;\n@@ -30,7 +29,7 @@ pub type ViewRecord = LogRecord;\n /// Wall-clock timestamp carried on the log envelope for domain events.\n pub fn event_timestamp(event: &Event) -> i64 {\n match event {\n- Event::VoteRecorded { ts, .. } | Event::EntityImported { ts, .. } => *ts,\n+ Event::VoteRecorded { ts, .. } => *ts,\n Event::NodeEnsured { .. } => crate::fetch::now_ms(),\n }\n }\n@@ -57,6 +56,4 @@ pub enum Event {\n },\n /// Register a node path in the fractal tree (no external fetch).\n NodeEnsured { id: String },\n- /// Full upstream API payload for a node (domain-specific view derived at replay/render time).\n- EntityImported { id: String, ts: i64, payload: Value },\n }\ndiff --git a/server/src/journal.rs b/server/src/journal.rs\nindex 50bc89f976edb82b7b0e49e954a8eccbbe82bf87..d50023aca7c3e74068de988449b11baee32d1ecf 100644\n--- a/server/src/journal.rs\n+++ b/server/src/journal.rs\n@@ -5,7 +5,6 @@ use std::sync::Arc;\n use tokio::sync::{mpsc, oneshot};\n \n use crate::{\n- entity_store::EntityStore,\n event_log::EventLog,\n events::{event_timestamp, Event, EventRecord},\n projection_apply,\n@@ -25,7 +24,6 @@ pub struct JournalClient {\n impl JournalClient {\n pub fn spawn(\n event_log: Arc,\n- entity_store: EntityStore,\n projection_store: ProjectionStore,\n next_seq: u64,\n ) -> Self {\n@@ -33,7 +31,6 @@ impl JournalClient {\n tokio::spawn(journal_worker(\n rx,\n event_log,\n- entity_store,\n projection_store,\n next_seq,\n ));\n@@ -62,7 +59,6 @@ impl JournalClient {\n async fn journal_worker(\n mut rx: mpsc::Receiver,\n event_log: Arc,\n- entity_store: EntityStore,\n projection_store: ProjectionStore,\n mut next_seq: u64,\n ) {\n@@ -75,7 +71,6 @@ async fn journal_worker(\n let result = append_and_project_batch(\n &event_log,\n &projection_store,\n- &entity_store,\n &mut next_seq,\n &batch,\n )\n@@ -99,7 +94,6 @@ async fn journal_worker(\n async fn append_and_project_batch(\n event_log: &EventLog,\n projection_store: &ProjectionStore,\n- entity_store: &EntityStore,\n next_seq: &mut u64,\n commands: &[JournalCommand],\n ) -> Result<(), String> {\n@@ -117,7 +111,7 @@ async fn append_and_project_batch(\n .await\n .map_err(|e| e.to_string())?;\n *next_seq = seq;\n- projection_apply::apply_records(projection_store, entity_store, &records)\n+ projection_apply::apply_records(projection_store, &records)\n .map_err(|e| format!(\"projection apply failed after durable append: {e}\"))\n }\n \n@@ -132,10 +126,9 @@ mod tests {\n let log_path = tmp.path().join(\"events.jsonl\");\n let event_log = Arc::new(EventLog::new(log_path));\n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n \n- let journal = JournalClient::spawn(event_log, entity_store, projection_store.clone(), 1);\n+ let journal = JournalClient::spawn(event_log, projection_store.clone(), 1);\n \n let j1 = journal.clone();\n let j2 = journal.clone();\n@@ -177,11 +170,9 @@ mod tests {\n .unwrap();\n \n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n projection_apply::apply_records(\n &projection_store,\n- &entity_store,\n &[EventRecord::new(\n 1,\n 1,\n@@ -196,7 +187,6 @@ mod tests {\n \n let journal = JournalClient::spawn(\n event_log.clone(),\n- entity_store,\n projection_store.clone(),\n next_seq,\n );\n@@ -219,10 +209,9 @@ mod tests {\n let log_path = tmp.path().join(\"events.jsonl\");\n let event_log = Arc::new(EventLog::new(log_path));\n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n let journal =\n- JournalClient::spawn(event_log.clone(), entity_store, projection_store.clone(), 1);\n+ JournalClient::spawn(event_log.clone(), projection_store.clone(), 1);\n \n journal\n .append_many(vec![\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex 5c02c8e704e4664453bad75d819df8a067668176..3dfc7c8acb8ed61bb73ade63e72768e402042cc5 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -1,5 +1,4 @@\n pub mod api;\n-pub mod entity_store;\n pub mod event_log;\n pub mod events;\n pub mod fetch;\ndiff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs\nindex 5644557a41b3e9497c7421b444155ae629fa79f1..9c8990a8af927f35d3344c8d0872a516aba56b86 100644\n--- a/server/src/projection_apply.rs\n+++ b/server/src/projection_apply.rs\n@@ -1,22 +1,20 @@\n //! Apply event-log records to the durable projection as precise point updates.\n //!\n //! Each batch of records lowers to reified durable writes (edge merges, child\n-//! links, voted-pair flags, recent-vote pushes, entity payloads) plus a cursor\n-//! advance, all committed in one atomic `DisableWal` batch. The cursor moving in\n-//! the same batch as the (non-idempotent) edge merges guarantees exactly-once\n-//! application across replay.\n+//! links, voted-pair flags, recent-vote pushes) plus a cursor advance, all\n+//! committed in one atomic `DisableWal` batch. The cursor moving in the same\n+//! batch as the (non-idempotent) edge merges guarantees exactly-once application\n+//! across replay.\n \n use std::collections::BTreeSet;\n \n use crate::{\n- entity_store::EntityStore,\n event_log::EventLogError,\n events::{Event, EventRecord},\n path_types::ItemId,\n projection_store::ProjectionStore,\n- reddit::entity_view_from_payload,\n reducer::VoteData,\n- storage_schema::{ensure_path_writes, entity_view_writes, vote_writes},\n+ storage_schema::{ensure_path_writes, vote_writes},\n };\n \n fn parse_event_id(id: &str) -> Result {\n@@ -38,7 +36,6 @@ fn parent_from_event_scope(scope: &str) -> ItemId {\n \n pub fn apply_records(\n projection_store: &ProjectionStore,\n- entity_store: &EntityStore,\n records: &[EventRecord],\n ) -> Result<(), EventLogError> {\n if records.is_empty() {\n@@ -79,14 +76,6 @@ pub fn apply_records(\n let parsed = parse_event_id(id)?;\n ensure_path_writes(&mut batch, &parsed);\n }\n- Event::EntityImported { id, payload, .. } => {\n- let parsed = parse_event_id(id)?;\n- let view = entity_view_from_payload(&parsed, payload);\n- entity_view_writes(&mut batch, &parsed, view.as_ref());\n- entity_store\n- .put_in_batch(&mut batch, &parsed, payload)\n- .map_err(|e| EventLogError::Apply(e.to_string()))?;\n- }\n }\n last_seq = record.seq;\n }\ndiff --git a/server/src/projection_store.rs b/server/src/projection_store.rs\nindex 30ee478f953e80d7322bdbfa521ae559ff08fa51..8576d671f351004426207894ac35594ddb0f70cf 100644\n--- a/server/src/projection_store.rs\n+++ b/server/src/projection_store.rs\n@@ -9,13 +9,16 @@ use durable::{Db, Durability, Write};\n \n use crate::{\n path_types::ItemId,\n- reducer::{GlobalTree, NodeState},\n- storage_schema::{load_node_state, node, NodeSchemaFields, Store, StoreFields},\n+ reducer::{EntityData, GlobalTree, NodeState},\n+ storage_schema::{\n+ entity_content_clear_writes, entity_content_writes, load_node_state, node, NodeSchemaFields,\n+ Store, StoreFields,\n+ },\n };\n \n const PROJECTION_CURSOR_KEY: &str = \"cursor\";\n const PROJECTION_SCHEMA_KEY: &str = \"schema_version\";\n-const PROJECTION_SCHEMA_VERSION: u64 = 2;\n+const PROJECTION_SCHEMA_VERSION: u64 = 3;\n \n #[derive(Debug, thiserror::Error)]\n pub enum ProjectionStoreError {\n@@ -148,6 +151,45 @@ impl ProjectionStore {\n )?;\n Ok(())\n }\n+\n+ /// Cache Reddit display content outside the event log (must be evicted per policy).\n+ pub fn put_ephemeral_content(\n+ &self,\n+ id: &ItemId,\n+ view: &EntityData,\n+ fetched_at: i64,\n+ ) -> Result<(), ProjectionStoreError> {\n+ let mut batch = self.db.batch();\n+ entity_content_writes(&mut batch, id, view, fetched_at);\n+ batch\n+ .commit_with(Durability::DisableWal)\n+ .map_err(ProjectionStoreError::from)?;\n+ Ok(())\n+ }\n+\n+ /// Drop cached display content older than `cutoff_ms` (votes and tree structure remain).\n+ pub fn evict_content_older_than(&self, cutoff_ms: i64) -> Result {\n+ let keys = Store::root().nodes().keys(&self.db)?;\n+ let mut batch = self.db.batch();\n+ let mut evicted = 0usize;\n+ for key in keys {\n+ let id = parse_node_key(&key)?;\n+ let np = node(&id);\n+ let Some(fetched_at) = np.fetched_at().get(&self.db)? else {\n+ continue;\n+ };\n+ if fetched_at > 0 && fetched_at < cutoff_ms {\n+ entity_content_clear_writes(&mut batch, &id);\n+ evicted += 1;\n+ }\n+ }\n+ if evicted > 0 {\n+ batch\n+ .commit_with(Durability::DisableWal)\n+ .map_err(ProjectionStoreError::from)?;\n+ }\n+ Ok(evicted)\n+ }\n }\n \n fn parse_node_key(key: &str) -> Result {\n@@ -162,7 +204,7 @@ fn parse_node_key(key: &str) -> Result {\n #[cfg(test)]\n mod tests {\n use super::*;\n- use crate::{entity_store::EntityStore, events::Event, projection_apply};\n+ use crate::{events::Event, projection_apply, reducer::EntityData};\n \n fn record(seq: u64, event: Event) -> crate::events::EventRecord {\n crate::events::EventRecord::new(seq, crate::events::event_timestamp(&event), event)\n@@ -172,7 +214,6 @@ mod tests {\n fn applies_and_loads_reducer_nodes() {\n let tmp = tempfile::tempdir().unwrap();\n let db = Db::open(tmp.path()).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let store = ProjectionStore::from_db(&db).unwrap();\n \n let event = Event::VoteRecorded {\n@@ -183,7 +224,7 @@ mod tests {\n ratio_right: 1,\n scope: String::new(),\n };\n- projection_apply::apply_records(&store, &entity_store, &[record(1, event)]).unwrap();\n+ projection_apply::apply_records(&store, &[record(1, event)]).unwrap();\n assert_eq!(store.last_applied_event_count().unwrap(), 1);\n \n let loaded = store.load_tree().unwrap();\n@@ -196,7 +237,6 @@ mod tests {\n fn hydrates_scope_with_child_nodes() {\n let tmp = tempfile::tempdir().unwrap();\n let db = Db::open(tmp.path()).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let store = ProjectionStore::from_db(&db).unwrap();\n \n let event = Event::VoteRecorded {\n@@ -207,11 +247,36 @@ mod tests {\n ratio_right: 1,\n scope: String::new(),\n };\n- projection_apply::apply_records(&store, &entity_store, &[record(1, event)]).unwrap();\n+ projection_apply::apply_records(&store, &[record(1, event)]).unwrap();\n \n let scoped = store.scope_tree(&ItemId::root()).unwrap();\n let root = scoped.get(&ItemId::root()).unwrap();\n assert_eq!(root.children.len(), 2);\n assert!(scoped.get(&ItemId::opaque(\"alpha\")).is_some());\n }\n+\n+ #[test]\n+ fn evicts_stale_ephemeral_content() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let db = Db::open(tmp.path()).unwrap();\n+ let store = ProjectionStore::from_db(&db).unwrap();\n+ let id = ItemId::from_url(\"https://reddit.com/r/rust\").unwrap();\n+ store\n+ .put_ephemeral_content(\n+ &id,\n+ &EntityData {\n+ title: \"Rust\".into(),\n+ author: None,\n+ body_html: None,\n+ thumb_url: None,\n+ image_url: None,\n+ link_url: None,\n+ },\n+ 1_000,\n+ )\n+ .unwrap();\n+ assert!(store.load_node(&id).unwrap().unwrap().data.is_some());\n+ assert_eq!(store.evict_content_older_than(2_000).unwrap(), 1);\n+ assert!(store.load_node(&id).unwrap().unwrap().data.is_none());\n+ }\n }\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 20b7f9e3f8be39268a1767d09f5cf81eaa6ae0df..a874814f8927192ee62cab2d0db1efd27dcd57b7 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -9,10 +9,13 @@ use serde_json::Value;\n use tokio::sync::{mpsc, oneshot};\n \n use crate::{\n- entity_store::EntityStore, events::Event, fetch::now_ms, journal::JournalClient,\n- path_types::ItemId, reducer::GlobalTree,\n+ events::Event, fetch::now_ms, journal::JournalClient,\n+ path_types::ItemId, projection_store::ProjectionStore,\n };\n \n+/// Reddit display content must not be retained longer than this (API policy).\n+pub const REDDIT_CONTENT_TTL: std::time::Duration = std::time::Duration::from_secs(48 * 3600);\n+\n #[derive(Debug, Clone, PartialEq, Eq)]\n pub enum FetchJobResult {\n /// Number of entities written (1 for self, N for children).\n@@ -70,7 +73,11 @@ struct OAuthToken {\n }\n \n impl RedditBroker {\n- pub fn spawn(journal: JournalClient, config: RedditApiConfig) -> Self {\n+ pub fn spawn(\n+ journal: JournalClient,\n+ projection_store: ProjectionStore,\n+ config: RedditApiConfig,\n+ ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n \n let mut headers = header::HeaderMap::new();\n@@ -93,7 +100,7 @@ impl RedditBroker {\n \"reddit worker started\"\n );\n \n- tokio::spawn(reddit_worker(rx, journal, client, config));\n+ tokio::spawn(reddit_worker(rx, journal, projection_store, client, config));\n \n Self { tx }\n }\n@@ -189,27 +196,50 @@ pub fn entity_view_from_payload(\n None\n }\n \n-pub fn apply_entity_import(\n- tree: &mut GlobalTree,\n- store: &EntityStore,\n- id: &ItemId,\n- payload: Value,\n-) -> Result<(), String> {\n- let view = entity_view_from_payload(id, &payload);\n- store.put(id, &payload).map_err(|e| e.to_string())?;\n- tree.apply_entity(id, view);\n- Ok(())\n-}\n-\n fn notify(done: Option>, result: FetchJobResult) {\n if let Some(tx) = done {\n let _ = tx.send(result);\n }\n }\n \n+async fn import_fetched_payload(\n+ kind: FetchKind,\n+ fetch_id: &ItemId,\n+ payload: Value,\n+ projection_store: &ProjectionStore,\n+ journal: &JournalClient,\n+) -> Result {\n+ let fetched_at = now_ms();\n+ let imports: Vec<(ItemId, Value)> = match kind {\n+ FetchKind::SelfEntity => vec![(fetch_id.clone(), payload)],\n+ FetchKind::Children => parse_children(fetch_id, &payload),\n+ };\n+\n+ for (id, child_payload) in &imports {\n+ if let Some(view) = entity_view_from_payload(id, child_payload) {\n+ projection_store\n+ .put_ephemeral_content(id, &view, fetched_at)\n+ .map_err(|e| e.to_string())?;\n+ }\n+ }\n+\n+ let events: Vec = imports\n+ .iter()\n+ .map(|(id, _)| Event::NodeEnsured {\n+ id: id.as_str().to_string(),\n+ })\n+ .collect();\n+ let written = events.len();\n+ if !events.is_empty() {\n+ journal.append_many(events).await?;\n+ }\n+ Ok(written)\n+}\n+\n async fn reddit_worker(\n mut rx: mpsc::Receiver,\n journal: JournalClient,\n+ projection_store: ProjectionStore,\n client: Client,\n config: RedditApiConfig,\n ) {\n@@ -276,33 +306,20 @@ async fn reddit_worker(\n \n match outcome {\n Ok(FetchOutcome::Payload(payload)) => {\n- let imports: Vec<(ItemId, Value)> = match kind {\n- FetchKind::SelfEntity => vec![(fetch_id.clone(), payload)],\n- FetchKind::Children => parse_children(&fetch_id, &payload),\n- };\n tracing::debug!(\n item = %fetch_id,\n ?kind,\n- count = imports.len(),\n- \"reddit fetch got payload, importing\"\n+ \"reddit fetch got payload, caching ephemerally\"\n );\n \n- let events: Vec = imports\n- .into_iter()\n- .map(|(child_id, child_payload)| Event::EntityImported {\n- id: child_id.as_str().to_string(),\n- ts: now_ms(),\n- payload: child_payload,\n- })\n- .collect();\n- let written = events.len();\n-\n- match journal.append_many(events).await {\n+ match import_fetched_payload(kind, &fetch_id, payload, &projection_store, &journal)\n+ .await\n+ {\n Err(e) => {\n- tracing::warn!(item = %fetch_id, err = %e, \"reddit import journal failed\");\n+ tracing::warn!(item = %fetch_id, err = %e, \"reddit import failed\");\n notify(done, FetchJobResult::Failed(e));\n }\n- Ok(()) => {\n+ Ok(written) => {\n recently_fetched.insert(key.clone(), Instant::now());\n current_delay = Duration::from_millis(600);\n tracing::info!(item = %fetch_id, ?kind, written, \"reddit import complete\");\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 1352a8f0771add3d868a1b30109b087a2a6dba6f..0c75c85150bb9e5f578bbadf58b3e43f8a80be4b 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -136,8 +136,7 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default, Serialize, Deserialize)]\n pub struct NodeState {\n pub id: ItemId,\n- /// Domain-specific view derived from imported payload (e.g. Reddit title/author).\n- /// Raw JSON lives in [`crate::entity_store::EntityStore`].\n+ /// Ephemeral display view (Reddit title/author/etc.; not event-logged).\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex e44849eec46123072b238afd40a1fdd51ce19bd9..247b9047a57956f76c4b6bef691662101e62a8f9 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -1,14 +1,14 @@\n use std::{error::Error, sync::Arc};\n \n use crate::{\n- entity_store::EntityStore,\n event_log::EventLog,\n events::Event,\n+ fetch::now_ms,\n journal::JournalClient,\n path_types::ItemId,\n projection_apply,\n projection_store::ProjectionStore,\n- reddit::{RedditApiConfig, RedditBroker},\n+ reddit::{RedditApiConfig, RedditBroker, REDDIT_CONTENT_TTL},\n reducer::{GlobalTree, VoteData},\n view_log::ViewLog,\n views::ViewStore,\n@@ -45,7 +45,6 @@ pub fn normalize_scope(raw: &str) -> String {\n \n async fn catch_up_projection(\n event_log: &EventLog,\n- entity_store: &EntityStore,\n projection_store: &ProjectionStore,\n ) -> Result<(), crate::event_log::EventLogError> {\n let after_seq = projection_store\n@@ -54,7 +53,7 @@ async fn catch_up_projection(\n \n let stats = event_log\n .replay_from(after_seq, |record| {\n- projection_apply::apply_records(projection_store, entity_store, &[record])\n+ projection_apply::apply_records(projection_store, &[record])\n })\n .await?;\n if after_seq > stats.last_seq {\n@@ -67,22 +66,34 @@ async fn catch_up_projection(\n Ok(())\n }\n \n+fn spawn_content_evictor(projection_store: ProjectionStore) {\n+ tokio::spawn(async move {\n+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(15 * 60));\n+ interval.tick().await;\n+ loop {\n+ interval.tick().await;\n+ let cutoff = now_ms() - REDDIT_CONTENT_TTL.as_millis() as i64;\n+ match projection_store.evict_content_older_than(cutoff) {\n+ Ok(0) => {}\n+ Ok(n) => tracing::info!(evicted = n, \"reddit display content TTL eviction\"),\n+ Err(e) => tracing::warn!(err = %e, \"reddit content TTL eviction failed\"),\n+ }\n+ }\n+ });\n+}\n+\n pub async fn rebuild_projection(\n cfg: &AppConfig,\n ) -> Result> {\n let event_log = EventLog::new(cfg.event_log_path.clone());\n let store_path = format!(\"{}/store\", cfg.data_dir);\n let db = durable::Db::open(std::path::Path::new(&store_path))?;\n- let entity_store = EntityStore::from_db(&db)?;\n let projection_store = ProjectionStore::from_db(&db)?;\n \n- entity_store.reset()?;\n projection_store.reset()?;\n \n let stats = event_log\n- .replay(|record| {\n- projection_apply::apply_records(&projection_store, &entity_store, &[record])\n- })\n+ .replay(|record| projection_apply::apply_records(&projection_store, &[record]))\n .await?;\n let cursor = projection_store.last_applied_event_count()?;\n if cursor != stats.last_seq {\n@@ -132,7 +143,6 @@ pub struct AppState {\n pub cfg: Arc,\n pub event_log: Arc,\n pub view_log: Arc,\n- pub entity_store: EntityStore,\n pub projection_store: ProjectionStore,\n pub views: ViewStore,\n journal: JournalClient,\n@@ -145,7 +155,6 @@ impl AppState {\n let view_log = Arc::new(ViewLog::new(cfg.views_log_path.clone()));\n let store_path = format!(\"{}/store\", cfg.data_dir);\n let db = durable::Db::open(std::path::Path::new(&store_path))?;\n- let entity_store = EntityStore::from_db(&db)?;\n let projection_store = ProjectionStore::from_db(&db)?;\n let views = ViewStore::from_db(&db)?;\n \n@@ -157,22 +166,25 @@ impl AppState {\n }\n views.spawn_worker(view_log.clone());\n \n- catch_up_projection(&event_log, &entity_store, &projection_store).await?;\n+ catch_up_projection(&event_log, &projection_store).await?;\n let next_seq = event_log.last_sequence().await? + 1;\n \n let journal = JournalClient::spawn(\n event_log.clone(),\n- entity_store.clone(),\n projection_store.clone(),\n next_seq,\n );\n- let reddit = RedditBroker::spawn(journal.clone(), RedditApiConfig::from_env());\n+ let reddit = RedditBroker::spawn(\n+ journal.clone(),\n+ projection_store.clone(),\n+ RedditApiConfig::from_env(),\n+ );\n+ spawn_content_evictor(projection_store.clone());\n \n Ok(Self {\n cfg: Arc::new(cfg),\n event_log,\n view_log,\n- entity_store,\n projection_store,\n views,\n journal,\n@@ -248,54 +260,72 @@ impl AppState {\n mod tests {\n use super::{normalize_scope, parse_item_param, AppConfig, AppState};\n use crate::{\n- entity_store::EntityStore, event_log::EventLog, events::Event, path_types::ItemId,\n- projection_apply, projection_store::ProjectionStore,\n+ event_log::EventLog, events::Event, path_types::ItemId, projection_apply,\n+ projection_store::ProjectionStore, reducer::EntityData,\n };\n- use serde_json::json;\n \n fn event_record(seq: u64, event: Event) -> crate::events::EventRecord {\n crate::events::EventRecord::new(seq, crate::events::event_timestamp(&event), event)\n }\n \n #[tokio::test]\n- async fn replay_entity_imported_restores_view() {\n+ async fn rebuild_projection_drops_ephemeral_content() {\n let tmp = tempfile::tempdir().unwrap();\n- let log_path = tmp.path().join(\"events.jsonl\");\n- let log = EventLog::new(log_path.to_string_lossy().into_owned());\n- let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n- let event = Event::EntityImported {\n- id: \"https://reddit.com/r/rust\".into(),\n- ts: 1,\n- payload: payload.clone(),\n- };\n- log.append(&event_record(1, event)).await.unwrap();\n+ let data_dir = tmp.path().to_string_lossy().into_owned();\n+ let log = EventLog::new(format!(\"{data_dir}/events.jsonl\"));\n+ log.append(&event_record(\n+ 1,\n+ Event::NodeEnsured {\n+ id: \"https://reddit.com/r/rust\".into(),\n+ },\n+ ))\n+ .await\n+ .unwrap();\n \n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n- super::catch_up_projection(&log, &entity_store, &projection_store)\n+ super::catch_up_projection(&log, &projection_store)\n .await\n .unwrap();\n- let tree = projection_store\n- .scope_tree(&ItemId::parse(\"https://reddit.com/r/rust\").unwrap())\n- .unwrap();\n- let node = tree\n- .get(&ItemId::parse(\"https://reddit.com/r/rust\").unwrap())\n- .unwrap();\n- assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n- let stored = entity_store\n- .get(&ItemId::parse(\"https://reddit.com/r/rust\").unwrap())\n- .unwrap()\n+ let id = ItemId::parse(\"https://reddit.com/r/rust\").unwrap();\n+ projection_store\n+ .put_ephemeral_content(\n+ &id,\n+ &EntityData {\n+ title: \"Rust\".into(),\n+ author: None,\n+ body_html: None,\n+ thumb_url: None,\n+ image_url: None,\n+ link_url: None,\n+ },\n+ 1,\n+ )\n .unwrap();\n- assert_eq!(stored[\"data\"][\"display_name\"], \"rust\");\n+ assert!(projection_store.load_node(&id).unwrap().unwrap().data.is_some());\n+ drop(projection_store);\n+ drop(db);\n+\n+ super::rebuild_projection(&AppConfig {\n+ data_dir: data_dir.clone(),\n+ event_log_path: format!(\"{data_dir}/events.jsonl\"),\n+ views_log_path: format!(\"{data_dir}/views.jsonl\"),\n+ port: 0,\n+ })\n+ .await\n+ .unwrap();\n+\n+ let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n+ let projection_store = ProjectionStore::from_db(&db).unwrap();\n+ let node = projection_store.load_node(&id).unwrap().unwrap();\n+ assert!(node.data.is_none());\n }\n \n #[tokio::test]\n- async fn rebuild_projection_restores_nodes_payloads_and_cursor_from_jsonl() {\n+ async fn rebuild_projection_restores_structure_and_cursor_from_jsonl() {\n let tmp = tempfile::tempdir().unwrap();\n let data_dir = tmp.path().to_string_lossy().into_owned();\n let log = EventLog::new(format!(\"{data_dir}/events.jsonl\"));\n- let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n log.append_batch(&[\n event_record(\n 1,\n@@ -305,16 +335,8 @@ mod tests {\n ),\n event_record(\n 2,\n- Event::EntityImported {\n- id: \"https://reddit.com/r/rust\".into(),\n- ts: 2,\n- payload: payload.clone(),\n- },\n- ),\n- event_record(\n- 3,\n Event::VoteRecorded {\n- ts: 3,\n+ ts: 2,\n a: \"alpha\".into(),\n b: \"beta\".into(),\n ratio_left: 2,\n@@ -328,11 +350,9 @@ mod tests {\n \n {\n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n projection_apply::apply_records(\n &projection_store,\n- &entity_store,\n &[event_record(\n 1,\n Event::NodeEnsured {\n@@ -352,13 +372,12 @@ mod tests {\n })\n .await\n .unwrap();\n- assert_eq!(stats.applied, 3);\n- assert_eq!(stats.last_seq, 3);\n+ assert_eq!(stats.applied, 2);\n+ assert_eq!(stats.last_seq, 2);\n \n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n- assert_eq!(projection_store.last_applied_event_count().unwrap(), 3);\n+ assert_eq!(projection_store.last_applied_event_count().unwrap(), 2);\n let tree = projection_store.scope_tree(&ItemId::root()).unwrap();\n let root = tree.get(&ItemId::root()).unwrap();\n assert!(root.children.contains(&ItemId::parse(\"alpha\").unwrap()));\n@@ -366,11 +385,6 @@ mod tests {\n .load_node(&ItemId::parse(\"https://reddit.com/r/stale\").unwrap())\n .unwrap()\n .is_none());\n- let stored = entity_store\n- .get(&ItemId::parse(\"https://reddit.com/r/rust\").unwrap())\n- .unwrap()\n- .unwrap();\n- assert_eq!(stored[\"data\"][\"display_name\"], \"rust\");\n }\n \n #[tokio::test]\n@@ -389,12 +403,9 @@ mod tests {\n \n {\n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n- // Advance the projection cursor to 2 while the log tail is only 1.\n projection_apply::apply_records(\n &projection_store,\n- &entity_store,\n &[event_record(\n 2,\n Event::NodeEnsured {\n@@ -403,7 +414,7 @@ mod tests {\n )],\n )\n .unwrap();\n- let err = super::catch_up_projection(&log, &entity_store, &projection_store)\n+ let err = super::catch_up_projection(&log, &projection_store)\n .await\n .unwrap_err();\n assert!(err\n@@ -432,10 +443,9 @@ mod tests {\n .unwrap();\n \n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n \n- super::catch_up_projection(&log, &entity_store, &projection_store)\n+ super::catch_up_projection(&log, &projection_store)\n .await\n .unwrap();\n assert_eq!(projection_store.last_applied_event_count().unwrap(), 1);\n@@ -444,7 +454,7 @@ mod tests {\n let first_edge_total: f64 = first_root.local_ranking.edges.values().sum();\n assert_eq!(first_edge_total, 3.0);\n \n- super::catch_up_projection(&log, &entity_store, &projection_store)\n+ super::catch_up_projection(&log, &projection_store)\n .await\n .unwrap();\n assert_eq!(projection_store.last_applied_event_count().unwrap(), 1);\n@@ -556,9 +566,8 @@ mod tests {\n .unwrap();\n {\n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n- super::catch_up_projection(&log, &entity_store, &projection_store)\n+ super::catch_up_projection(&log, &projection_store)\n .await\n .unwrap();\n }\n@@ -605,9 +614,8 @@ mod tests {\n .unwrap();\n {\n let db = durable::Db::open(tmp.path().join(\"store\")).unwrap();\n- let entity_store = EntityStore::from_db(&db).unwrap();\n let projection_store = ProjectionStore::from_db(&db).unwrap();\n- super::catch_up_projection(&log, &entity_store, &projection_store)\n+ super::catch_up_projection(&log, &projection_store)\n .await\n .unwrap();\n }\ndiff --git a/server/src/storage_dto.rs b/server/src/storage_dto.rs\nindex de5ed995797ae306d3a71394a4d9d245ffd5771d..9dfb13c53efe4389277625a6ab3bfc18f566a453 100644\n--- a/server/src/storage_dto.rs\n+++ b/server/src/storage_dto.rs\n@@ -3,17 +3,15 @@\n //! Node structure (children, edges, voted pairs, recent votes) is no longer a\n //! single blob — it lives as point-addressable durable collections (see\n //! [`crate::storage_schema`]). This module only defines the small leaf values:\n-//! the derived entity view, raw entity payloads, and individual votes.\n+//! ephemeral entity views and individual votes.\n \n use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n \n use crate::{\n path_types::ItemId,\n reducer::{EntityData, VoteData},\n };\n \n-pub const ENTITY_RECORD_VERSION: u32 = 1;\n pub const VOTE_RECORD_VERSION: u32 = 1;\n pub const ENTITY_DATA_VERSION: u32 = 1;\n \n@@ -29,14 +27,7 @@ impl Versioned {\n }\n }\n \n-pub type StoredEntityRecord = Versioned;\n-\n-#[derive(Debug, Clone, Serialize, Deserialize)]\n-pub struct StoredEntityV1 {\n- pub json: Value,\n-}\n-\n-/// Derived entity view stored at a node's `data` leaf.\n+/// Derived entity view stored at a node's `data` leaf (ephemeral; not logged).\n #[derive(Debug, Clone, Serialize, Deserialize)]\n pub struct StoredEntityDataV1 {\n pub version: u32,\n@@ -63,25 +54,6 @@ pub struct StoredVoteV1 {\n pub thread_tag: String,\n }\n \n-pub fn encode_entity_payload(payload: &Value) -> StoredEntityRecord {\n- Versioned::new(\n- ENTITY_RECORD_VERSION,\n- StoredEntityV1 {\n- json: payload.clone(),\n- },\n- )\n-}\n-\n-pub fn decode_entity_payload(record: StoredEntityRecord) -> Result {\n- if record.version != ENTITY_RECORD_VERSION {\n- return Err(format!(\n- \"unsupported entity record version: {}\",\n- record.version\n- ));\n- }\n- Ok(record.payload.json)\n-}\n-\n pub fn encode_entity_data(data: &EntityData) -> StoredEntityDataV1 {\n StoredEntityDataV1 {\n version: ENTITY_DATA_VERSION,\ndiff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs\nindex 76bf7bc74a2c5ef4f78678a333b7661778f5b835..bd26e665e084b95b10fdfff091c31e8dc84d07b8 100644\n--- a/server/src/storage_schema.rs\n+++ b/server/src/storage_schema.rs\n@@ -15,7 +15,7 @@ use crate::{\n reducer::{EntityData, GroupState, NodeState, VoteData},\n storage_dto::{\n decode_entity_data, decode_vote, encode_entity_data, encode_vote, parse_stored_id,\n- StoredEntityDataV1, StoredEntityRecord, StoredVoteV1,\n+ StoredEntityDataV1, StoredVoteV1,\n },\n };\n \n@@ -40,17 +40,17 @@ pub struct NodeSchema {\n pub voted_pairs: Map>,\n /// Recent votes, newest at the front (capped on write).\n pub recent_votes: Deque>,\n+ /// When ephemeral Reddit display content was last fetched (ms); absent after eviction.\n+ pub fetched_at: Leaf,\n }\n \n-/// The single database root: nodes, raw payloads, view counts, and per-concern\n-/// metadata maps (cursors and schema versions).\n+/// The single database root: nodes, view counts, and per-concern metadata maps\n+/// (cursors and schema versions).\n #[derive(Durable)]\n #[allow(dead_code)]\n pub struct Store {\n pub nodes: Map,\n pub proj_meta: Map>,\n- pub entities: Map>,\n- pub entity_meta: Map>,\n pub view_counts: Map>,\n pub view_meta: Map>,\n }\n@@ -264,12 +264,17 @@ pub fn vote_writes(\n Ok(())\n }\n \n-/// Reified writes for an imported entity view (node data + path wiring).\n-pub fn entity_view_writes(batch: &mut Batch, id: &ItemId, view: Option<&EntityData>) {\n+/// Reified writes for ephemeral Reddit display content (not event-logged).\n+pub fn entity_content_writes(batch: &mut Batch, id: &ItemId, view: &EntityData, fetched_at: i64) {\n ensure_path_writes(batch, id);\n- if let Some(view) = view {\n- batch.write(node(id).data().set(&encode_entity_data(view)));\n- }\n+ batch.write(node(id).data().set(&encode_entity_data(view)));\n+ batch.write(node(id).fetched_at().set(&fetched_at));\n+}\n+\n+/// Clear cached display content for one node (structure/votes are untouched).\n+pub fn entity_content_clear_writes(batch: &mut Batch, id: &ItemId) {\n+ batch.write(node(id).data().delete());\n+ batch.write(node(id).fetched_at().delete());\n }\n \n #[cfg(test)]\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nindex b476488526252c13fd73bdda76e5201678e4a714..6d8c5bca7ebad0b5abfddecd4ea48cc09d738ebe 100644\n--- a/test/reddit_import.clj\n+++ b/test/reddit_import.clj\n@@ -59,9 +59,10 @@\n \"curl\" \"-sf\" browse-url))\n log (slurp (io/file log-path))]\n (is (str/includes? after \"The Rust Programming Language\"))\n- (is (str/includes? log \"\\\"type\\\":\\\"entity_imported\\\"\"))\n- (is (str/includes? log \"\\\"subscribers\\\":350000\"))\n- (is (str/includes? log \"\\\"display_name\\\":\\\"rust\\\"\")))\n+ (is (str/includes? log \"\\\"type\\\":\\\"node_ensured\\\"\"))\n+ (is (not (str/includes? log \"\\\"subscribers\\\"\")))\n+ (is (not (str/includes? log \"\\\"display_name\\\"\")))\n+ (is (not (str/includes? log \"entity_imported\"))))\n (let [children-sse (curl-fetch-ui-sse app-base \"reddit.com/r/rust\" \"children\")]\n (is (zero? (:exit children-sse)) \"POST /ui fetch_entity (children) SSE succeeds\")\n (is (str/includes? (:out children-sse) \"Idiomorph.morph\"))\n@@ -71,10 +72,12 @@\n log2 (slurp (io/file log-path))]\n (is (str/includes? after-children \"Announcing Rust 1.99\"))\n (is (str/includes? after-children \"Unranked\"))\n- (is (str/includes? log2 \"announcing_rust_199\")))))\n+ (is (str/includes? log2 \"\\\"type\\\":\\\"node_ensured\\\"\"))\n+ (is (str/includes? log2 \"/comments/\"))\n+ (is (not (str/includes? log2 \"\\\"selftext\\\"\"))))))\n \n (deftest reddit-fetch-via-mock-api\n- (testing \"Fetch more queues import; event log stores full payload; page shows title\"\n+ (testing \"Fetch caches display content ephemerally; log records structure only\"\n (let [root (repo-root)\n fixtures (mock-reddit/fixtures-dir root)\n data-dir (.getAbsolutePath\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[1d9d8ade] init seed\n\nSide B — unified diff (full patch):\ndiff --git a/TEST.sh b/TEST.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..266b71f29259f5c2cad2617476ebe2ebc9596d39\n--- /dev/null\n+++ b/TEST.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+cargo test --all\n+./scripts/clj-test.sh\ndiff --git a/bundle.js b/bundle.js\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265\n--- /dev/null\n+++ b/bundle.js\n@@ -0,0 +1,52 @@\n+/**\n+ * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here.\n+ */\n+(function () {\n+ function evalJs(js) {\n+ if (js && String(js).trim()) {\n+ eval(js);\n+ }\n+ }\n+\n+ // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie)\n+\n+ function initSlugUi() {\n+ // POST forms → eval response (except theme + full-navigation forms)\n+ document.addEventListener('submit', async function (e) {\n+ var f = e.target;\n+ if (!f || f.tagName !== 'FORM') return;\n+ if ((f.method || 'get').toLowerCase() !== 'post') return;\n+ if (f.id === 'slug-theme-form') return;\n+ if (f.getAttribute('data-navigate') === 'full') return;\n+ e.preventDefault();\n+ var resp = await fetch(f.action, {\n+ method: 'POST',\n+ body: new URLSearchParams(new FormData(f)),\n+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n+ credentials: 'same-origin',\n+ });\n+ evalJs(await resp.text());\n+ });\n+\n+ // SSE: server-pushed JS\n+ function connectSSE() {\n+ var ssePath = window.location.pathname + window.location.search;\n+ var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath));\n+ es.onmessage = function (e) {\n+ evalJs(e.data);\n+ };\n+ es.onerror = function () {\n+ es.close();\n+ setTimeout(connectSSE, 3000);\n+ };\n+ }\n+ connectSSE();\n+ }\n+\n+ if (document.readyState === 'loading') {\n+ document.addEventListener('DOMContentLoaded', initSlugUi);\n+ } else {\n+ initSlugUi();\n+ }\n+})();\n+\ndiff --git a/clj-test.sh b/clj-test.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952\n--- /dev/null\n+++ b/clj-test.sh\n@@ -0,0 +1,5 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+cd \"$(dirname \"$0\")/..\"\n+mkdir -p target\n+exec clojure -M:kaocha\ndiff --git a/forms.rs b/forms.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a\n--- /dev/null\n+++ b/forms.rs\n@@ -0,0 +1,145 @@\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs\n+//! Plan2-style JSON templates with `{\"$form\": \"field_name\"}` holes, filled from\n+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**\n+//! deserializing into a typed struct.\n+//!\n+//! # Wire format\n+//!\n+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty\n+//! printing, strings escaped per JSON rules (`\\\"`, `\\n`, etc.). Embed that string\n+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not\n+//! bespoke encodings.\n+//!\n+//! # Power vs flat hidden fields\n+//!\n+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),\n+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,\n+//! arrays, and optional fields without inventing a new naming scheme each time.\n+//!\n+//! # Security\n+//!\n+//! Substitution runs **before** `serde` into your command type. It does not fix\n+//! authorization: if the client can replace the hidden `__rpc__` value, they can\n+//! change the command shape unless you validate (signed blob, server-side session\n+//! context, or treat the blob as hints only). Same threat model as any hidden field.\n+\n+use serde::Serialize;\n+use serde_json::Value;\n+use std::collections::HashMap;\n+\n+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.\n+pub fn template_json_compact(v: &T) -> serde_json::Result {\n+ serde_json::to_string(v)\n+}\n+\n+/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n+/// string for `key` (empty if missing). Other keys are unchanged.\n+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n+ match val {\n+ Value::Object(map) => {\n+ if map.len() == 1 {\n+ if let Some(Value::String(field_name)) = map.get(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = Value::String(submitted.to_string());\n+ return;\n+ }\n+ }\n+ for v in map.values_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ Value::Array(arr) => {\n+ for v in arr.iter_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ _ => {}\n+ }\n+}\n+\n+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.\n+pub fn fill_template_from_form(\n+ template_json: &str,\n+ form_data: &HashMap,\n+) -> Result {\n+ let mut v: Value = serde_json::from_str(template_json)?;\n+ substitute_form_vars(&mut v, form_data);\n+ Ok(v)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde::Deserialize;\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Demo {\n+ room: String,\n+ thread_tag: String,\n+ nested: Nested,\n+ }\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Nested {\n+ text: String,\n+ }\n+\n+ #[test]\n+ fn holes_become_strings() {\n+ let json = r#\"{\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"tag\"},\n+ \"nested\": {\"text\": {\"$form\": \"body\"}}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"tag\".into(), \"foo\".into());\n+ form.insert(\"body\".into(), \"hello\\nworld\".into());\n+\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ let d: Demo = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ d,\n+ Demo {\n+ room: \"public\".into(),\n+ thread_tag: \"foo\".into(),\n+ nested: Nested {\n+ text: \"hello\\nworld\".into(),\n+ },\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn missing_form_key_is_empty_string() {\n+ let json = r#\"{\"x\": {\"$form\": \"nope\"}}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"other\".into(), \"y\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"x\"], \"\");\n+ }\n+\n+ #[test]\n+ fn array_of_holes() {\n+ let json = r#\"{\"items\": [{\"$form\": \"a\"}, {\"$form\": \"b\"}]}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"a\".into(), \"1\".into());\n+ form.insert(\"b\".into(), \"2\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"items\"], serde_json::json!([\"1\", \"2\"]));\n+ }\n+\n+ #[test]\n+ fn template_json_compact_escapes_and_single_line() {\n+ let s = template_json_compact(&serde_json::json!({\n+ \"x\": \"quote\\\"and\\nnewline\"\n+ }))\n+ .unwrap();\n+ assert!(!s.contains('\\n'));\n+ assert!(s.contains(\"\\\\\\\"\") || s.contains(\"\\\\n\"));\n+ }\n+}\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ \n+\ndiff --git a/gameifying.tdsl b/gameifying.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15\n--- /dev/null\n+++ b/gameifying.tdsl\n@@ -0,0 +1,2 @@\n+consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. \n+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.\ndiff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a\n--- /dev/null\n+++ b/pagerank_streaming.tdsl\n@@ -0,0 +1,11 @@\n+eventually i want to have ranking histories.\n+like \"visionary\" and \"fraud\" waxing and waning in a uplot graph over time for #elon-musk. \n+there are too many query combinations to precomupte the ranking histories \n+(arbitrary user filters, and tag overlap combinations maybe), \n+so we're just going to have to calculate them all on demand. \n+computers are fast, its okay. for each vote, we need to calculate rank centrality again. \n+i was thinking, for n votes we calculate the rank centrality, \n+and get node weights. we then output that data to the client (over websocket, or testable barrier). \n+then we calculate n+1 votes, _but we keep the node weights in memory_ \n+so the rank centrality process converges faster. \n+this also has the side effect of making the ranking stream in satisfyingly as you load the page.\ndiff --git a/parser.rs b/parser.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349\n--- /dev/null\n+++ b/parser.rs\n@@ -0,0 +1,1808 @@\n+use std::collections::HashMap;\n+use std::rc::Rc;\n+use std::cell::RefCell;\n+use crate::ui::action::UIAction;\n+use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};\n+\n+// --- Core Abstractions ---\n+\n+/// Unique identifier for nodes in the graph\n+type NodeId = &'static str;\n+\n+/// Pattern matching for edges\n+#[derive(Debug, Clone)]\n+pub enum EdgePattern {\n+ /// Matches exact literal string\n+ Literal(&'static str),\n+ \n+ /// Matches any prefix of a string and suggests the full string\n+ /// e.g., PrefixOf(\"reddit.com\") matches \"r\", \"re\", \"red\", \"reddit\", \"reddit.com\"\n+ PrefixOf(&'static str),\n+ \n+ /// Captures a variable segment (e.g., subreddit name, username)\n+ Variable(&'static str),\n+ \n+ /// Matches any string (wildcard)\n+ Any,\n+}\n+\n+impl EdgePattern {\n+ /// Try to match this pattern against input, return (consumed_chars, captured_value)\n+ fn matches(&self, input: &str) -> Option<(usize, Option)> {\n+ match self {\n+ EdgePattern::Literal(lit) => {\n+ if input.starts_with(lit) {\n+ Some((lit.len(), None))\n+ } else {\n+ None\n+ }\n+ }\n+ EdgePattern::PrefixOf(target) => {\n+ // Check if input is a prefix of target\n+ if target.starts_with(input) && !input.is_empty() {\n+ // It's a valid prefix\n+ Some((input.len(), None))\n+ } else if input.starts_with(target) {\n+ // Full match\n+ Some((target.len(), None))\n+ } else {\n+ None\n+ }\n+ }\n+ EdgePattern::Variable(var_name) => {\n+ // Consume until next '/' or end of string\n+ let end = input.find('/').unwrap_or(input.len());\n+ if end > 0 {\n+ let captured = input[..end].to_string();\n+ // Validate based on variable type\n+ if is_valid_variable(var_name, &captured) {\n+ Some((end, Some(captured)))\n+ } else {\n+ None\n+ }\n+ } else {\n+ None\n+ }\n+ }\n+ EdgePattern::Any => {\n+ // Match everything until next '/' or end\n+ let end = input.find('/').unwrap_or(input.len());\n+ if end > 0 {\n+ Some((end, Some(input[..end].to_string())))\n+ } else {\n+ None\n+ }\n+ }\n+ }\n+ }\n+ \n+ /// Get the completion suggestion for this pattern\n+ fn completion(&self, partial: &str) -> Option {\n+ match self {\n+ EdgePattern::PrefixOf(target) => {\n+ if target.starts_with(partial) && partial != *target {\n+ Some(target.to_string())\n+ } else {\n+ None\n+ }\n+ }\n+ _ => None,\n+ }\n+ }\n+}\n+\n+/// Edge in the graph\n+pub struct Edge {\n+ pattern: EdgePattern,\n+ target: NodeId,\n+ /// Optional description for autocomplete\n+ description: Option<&'static str>,\n+}\n+\n+/// Handler function for generating UI actions\n+type Handler = Box) -> UIAction>;\n+\n+/// Node in the graph\n+pub struct Node {\n+ #[allow(dead_code)]\n+ id: NodeId,\n+ edges: Vec,\n+ handler: Option,\n+}\n+\n+/// The composable parser graph\n+pub struct Graph {\n+ nodes: HashMap>>,\n+ root: NodeId,\n+}\n+\n+// --- Graph Builder (Fluent API) ---\n+\n+pub struct GraphBuilder {\n+ nodes: HashMap>>,\n+ current_node: Option,\n+ root: NodeId,\n+}\n+\n+impl GraphBuilder {\n+ pub fn new() -> Self {\n+ let mut nodes = HashMap::new();\n+ let root_node = Rc::new(RefCell::new(Node {\n+ id: \"root\",\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ nodes.insert(\"root\", root_node);\n+ \n+ GraphBuilder {\n+ nodes,\n+ current_node: Some(\"root\"),\n+ root: \"root\",\n+ }\n+ }\n+ \n+ /// Select a node to add edges to\n+ pub fn at(mut self, node_id: NodeId) -> Self {\n+ // Create node if it doesn't exist\n+ if !self.nodes.contains_key(node_id) {\n+ let node = Rc::new(RefCell::new(Node {\n+ id: node_id,\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ self.nodes.insert(node_id, node);\n+ }\n+ self.current_node = Some(node_id);\n+ self\n+ }\n+ \n+ /// Add an edge from the current node\n+ pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {\n+ self.edge_with_desc(pattern, target, None)\n+ }\n+ \n+ /// Add an edge with description\n+ pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {\n+ let current = self.current_node.expect(\"No current node selected\");\n+ \n+ // Create target node if it doesn't exist\n+ if !self.nodes.contains_key(target) {\n+ let node = Rc::new(RefCell::new(Node {\n+ id: target,\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ self.nodes.insert(target, node);\n+ }\n+ \n+ // Add edge to current node\n+ if let Some(node) = self.nodes.get(current) {\n+ node.borrow_mut().edges.push(Edge {\n+ pattern,\n+ target,\n+ description: desc,\n+ });\n+ }\n+ \n+ self\n+ }\n+ \n+ /// Set handler for current node\n+ pub fn handler(self, handler: F) -> Self \n+ where \n+ F: Fn(&str, &str, &HashMap) -> UIAction + 'static\n+ {\n+ let current = self.current_node.expect(\"No current node selected\");\n+ if let Some(node) = self.nodes.get(current) {\n+ node.borrow_mut().handler = Some(Box::new(handler));\n+ }\n+ self\n+ }\n+ \n+ /// Build the final graph\n+ pub fn build(self) -> Graph {\n+ Graph {\n+ nodes: self.nodes,\n+ root: self.root,\n+ }\n+ }\n+}\n+\n+// --- Parser Implementation ---\n+\n+impl Graph {\n+ pub fn parse(&self, input: &str) -> UIAction {\n+ let normalized = input.trim().to_lowercase();\n+ let mut state = ParserState {\n+ input: &normalized,\n+ cursor: 0,\n+ current_node_id: self.root,\n+ context: HashMap::new(),\n+ original_query: input.to_string(),\n+ current_prefix: String::new(),\n+ };\n+ \n+ self.parse_recursive(&mut state)\n+ }\n+ \n+ fn parse_recursive(&self, state: &mut ParserState) -> UIAction {\n+ let node = self.nodes.get(state.current_node_id)\n+ .expect(\"Node not found in graph\");\n+ let node_ref = node.borrow();\n+ \n+ // If we've consumed all input, check for handler or suggestions\n+ if state.cursor >= state.input.len() {\n+ if let Some(handler) = &node_ref.handler {\n+ return handler(&state.original_query, &state.current_prefix, &state.context);\n+ }\n+ \n+ // No handler, try to suggest based on available edges\n+ return self.suggest_from_edges(&node_ref, state);\n+ }\n+ \n+ let remaining = &state.input[state.cursor..];\n+ \n+ // Try to match each edge\n+ for edge in &node_ref.edges {\n+ if let Some((consumed, captured)) = edge.pattern.matches(remaining) {\n+ // Save state for potential backtracking\n+ let saved_cursor = state.cursor;\n+ let saved_node = state.current_node_id;\n+ let saved_prefix = state.current_prefix.clone();\n+ \n+ // Update state\n+ state.cursor += consumed;\n+ state.current_node_id = edge.target;\n+ state.current_prefix.push_str(&remaining[..consumed]);\n+ \n+ // Store captured variable if any\n+ if let Some(value) = captured {\n+ if let EdgePattern::Variable(var_name) = &edge.pattern {\n+ state.context.insert(var_name.to_string(), value);\n+ }\n+ }\n+ \n+ // Check if this is a partial match that needs completion\n+ if state.cursor == state.input.len() {\n+ if let Some(completion_suffix) = edge.pattern.completion(remaining) {\n+ // Use the current_prefix plus the completion suffix\n+ let full_completion = format!(\"{}{}\", \n+ state.current_prefix, \n+ completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)\n+ );\n+ return UIAction::suggest(\n+ state.original_query.clone(),\n+ Some(Suggestion {\n+ text: full_completion.clone(),\n+ completion: full_completion,\n+ description: edge.description.map(|d| d.to_string()),\n+ score: 1.0,\n+ })\n+ );\n+ }\n+ }\n+ \n+ // Continue parsing from the target node\n+ let result = self.parse_recursive(state);\n+ \n+ // If we got a valid response, return it\n+ if !matches!(result, UIAction::ShowError(_)) {\n+ return result;\n+ }\n+ \n+ // Otherwise, restore state and try next edge\n+ state.cursor = saved_cursor;\n+ state.current_node_id = saved_node;\n+ state.current_prefix = saved_prefix;\n+ }\n+ }\n+ \n+ // No edges matched - try to provide suggestions\n+ self.suggest_from_edges(&node_ref, state)\n+ }\n+ \n+ fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {\n+ let remaining = &state.input[state.cursor..];\n+ \n+ // Find edges that could match with more input\n+ for edge in &node.edges {\n+ match &edge.pattern {\n+ EdgePattern::PrefixOf(target) => {\n+ if target.starts_with(remaining) && !remaining.is_empty() {\n+ // Use current_prefix instead of rebuilding from input\n+ let full_completion = format!(\"{}{}\", state.current_prefix, target);\n+ return UIAction::suggest(\n+ state.original_query.clone(),\n+ Some(Suggestion {\n+ text: full_completion.clone(),\n+ completion: full_completion,\n+ description: edge.description.map(|d| d.to_string()),\n+ score: 1.0,\n+ })\n+ );\n+ }\n+ }\n+ EdgePattern::Literal(lit) => {\n+ if lit.starts_with(remaining) && !remaining.is_empty() {\n+ let full_completion = format!(\"{}{}\", state.current_prefix, lit);\n+ return UIAction::suggest(\n+ state.original_query.clone(),\n+ Some(Suggestion {\n+ text: full_completion.clone(),\n+ completion: full_completion,\n+ description: edge.description.map(|d| d.to_string()),\n+ score: 1.0,\n+ })\n+ );\n+ }\n+ }\n+ _ => {}\n+ }\n+ }\n+ \n+ UIAction::error(\n+ \"InvalidPath\".to_string(),\n+ format!(\"'{}' doesn't match any known pattern\", state.original_query)\n+ )\n+ }\n+}\n+\n+struct ParserState<'a> {\n+ input: &'a str,\n+ cursor: usize,\n+ current_node_id: NodeId,\n+ context: HashMap,\n+ original_query: String,\n+ current_prefix: String,\n+}\n+\n+// --- Helper Functions ---\n+\n+fn is_valid_variable(var_name: &str, value: &str) -> bool {\n+ match var_name {\n+ \"subreddit\" => {\n+ !value.is_empty() && \n+ value.len() <= 21 && \n+ value.chars().all(|c| c.is_alphanumeric() || c == '_')\n+ }\n+ \"username\" => {\n+ !value.is_empty() && \n+ value.len() <= 20 && \n+ value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')\n+ }\n+ \"post_id\" => {\n+ !value.is_empty() && \n+ value.len() <= 10 && \n+ value.chars().all(|c| c.is_alphanumeric())\n+ }\n+ _ => true, // Allow any value for unknown variables\n+ }\n+}\n+\n+// --- Define the Reddit Graph ---\n+\n+pub fn build_reddit_graph() -> Graph {\n+ GraphBuilder::new()\n+ // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===\n+ .at(\"root\")\n+ // Direct aliases to subreddit and user selection\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"r/\"),\n+ \"subreddit_selection\",\n+ Some(\"Browse subreddits (e.g., r/programming)\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"u/\"),\n+ \"user_selection\",\n+ Some(\"Browse users (e.g., u/spez)\")\n+ )\n+ \n+ // Reddit shortcuts - one pattern handles ALL prefixes!\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"), \n+ \"reddit_domain\",\n+ Some(\"Go to Reddit\")\n+ )\n+ \n+ // Protocol patterns - \"h\" can suggest \"https://\"\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"https://\"),\n+ \"https_protocol\",\n+ Some(\"HTTPS protocol\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"http://\"),\n+ \"http_protocol\", \n+ Some(\"HTTP protocol\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"www.\"),\n+ \"www_prefix\",\n+ Some(\"World Wide Web\")\n+ )\n+ \n+\n+ \n+ // === HTTPS PROTOCOL: Can go to any domain ===\n+ .at(\"https_protocol\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit (HTTPS)\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"www.\"),\n+ \"https_www\",\n+ Some(\"WWW prefix\")\n+ )\n+ \n+ // === HTTP PROTOCOL: Similar to HTTPS ===\n+ .at(\"http_protocol\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit (HTTP)\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"www.\"),\n+ \"http_www\",\n+ Some(\"WWW prefix\")\n+ )\n+ \n+ // === HTTPS + WWW ===\n+ .at(\"https_www\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit\")\n+ )\n+ \n+ // === HTTP + WWW ===\n+ .at(\"http_www\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit\")\n+ )\n+ \n+ // === WWW PREFIX (without protocol) ===\n+ .at(\"www_prefix\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit\")\n+ )\n+ \n+ // === REDDIT DOMAIN: Expect \"/\" ===\n+ .at(\"reddit_domain\")\n+ .edge(EdgePattern::Literal(\"/\"), \"reddit_root\")\n+ .handler(|query, prefix, _ctx| {\n+ // If someone just types \"reddit.com\" (or with protocol) without slash\n+ // Suggest adding the slash using the current prefix\n+ let completion = format!(\"{}/\", prefix);\n+ \n+ UIAction::suggest(\n+ query.to_string(),\n+ Some(Suggestion {\n+ text: completion.clone(),\n+ completion,\n+ description: Some(\"Continue to Reddit homepage\".to_string()),\n+ score: 1.0,\n+ })\n+ )\n+ })\n+ \n+ // === REDDIT ROOT: The main Reddit navigation ===\n+ .at(\"reddit_root\")\n+ .edge(EdgePattern::Literal(\"r/\"), \"subreddit_selection\")\n+ .edge(EdgePattern::Literal(\"u/\"), \"user_selection\")\n+ .handler(|query, prefix, _ctx| {\n+ UIAction::multiple(vec![\n+ UIAction::scrolling_suggestions(\n+ query.to_string(),\n+ vec![\n+ ScrollingSuggestion {\n+ completion: format!(\"{}r/\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}u/\", prefix),\n+ },\n+ ],\n+ 1400, // 1.4 second interval (slower)\n+ true // loop through\n+ ),\n+ UIAction::guide(\n+ query.to_string(),\n+ \"Welcome to Sorter for Reddit\".to_string(),\n+ \"Where would you like to start?\".to_string(),\n+ vec![\n+ GuideOption { \n+ key: \"r\".to_string(), \n+ label: \"Sort a Subreddit\".to_string(), \n+ description: \"Find the best posts in a community.\".to_string(), \n+ completion: format!(\"{}r/\", prefix)\n+ },\n+ GuideOption { \n+ key: \"u\".to_string(), \n+ label: \"Sort User Content\".to_string(), \n+ description: \"Explore and rank a user's posts and comments.\".to_string(), \n+ completion: format!(\"{}u/\", prefix)\n+ },\n+ ]\n+ ),\n+ ])\n+ })\n+ \n+\n+ \n+ // === SUBREDDIT SELECTION: THE UNIFIED NODE ===\n+ // This node is now reached from `r/` OR `reddit.com/r/`\n+ .at(\"subreddit_selection\")\n+ .edge(EdgePattern::Variable(\"subreddit\"), \"subreddit_page\")\n+ .handler(|query, prefix, _ctx| {\n+ UIAction::multiple(vec![\n+ UIAction::scrolling_suggestions(\n+ query.to_string(),\n+ vec![\n+ ScrollingSuggestion {\n+ completion: format!(\"{}programming\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}askreddit\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}aww\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}rust\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}webdev\", prefix),\n+ },\n+ ],\n+ 1600, // 1.6 second interval (slower)\n+ true // loop through\n+ ),\n+ // Live DB-backed suggestions for subreddits as the user types\n+ UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }\n+ ])\n+ })\n+ \n+ // === Specific subreddit page ===\n+ .at(\"subreddit_page\")\n+ .edge(EdgePattern::Literal(\"/\"), \"subreddit_slash\")\n+ .handler(|_query, prefix, ctx| {\n+ // Use the new unified subreddit resolution logic\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::ResolveAndDisplaySubreddit {\n+ subreddit,\n+ prefix: prefix.to_string(),\n+ }\n+ })\n+ \n+ .at(\"subreddit_slash\")\n+ .edge(EdgePattern::Literal(\"hot\"), \"subreddit_hot\")\n+ .edge(EdgePattern::Literal(\"top\"), \"subreddit_top\")\n+ .edge(EdgePattern::Literal(\"new\"), \"subreddit_new\")\n+ .edge(EdgePattern::Literal(\"comments\"), \"subreddit_comments\")\n+ .handler(|query, prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::guide(\n+ query.to_string(), \n+ format!(\"What to sort in r/{}?\", subreddit), \n+ \"Choose a category to begin sorting.\".to_string(), \n+ vec![\n+ GuideOption { \n+ key: \"hot\".to_string(), \n+ label: \"Hot Posts\".to_string(), \n+ description: \"Import and sort posts currently on the front page.\".to_string(), \n+ completion: format!(\"{}hot\", prefix) \n+ },\n+ GuideOption { \n+ key: \"top\".to_string(), \n+ label: \"Top Posts\".to_string(), \n+ description: \"Import and sort the highest-rated posts.\".to_string(), \n+ completion: format!(\"{}top\", prefix) \n+ },\n+ GuideOption { \n+ key: \"new\".to_string(), \n+ label: \"New Posts\".to_string(), \n+ description: \"Import and sort the newest posts.\".to_string(), \n+ completion: format!(\"{}new\", prefix) \n+ },\n+ GuideOption { \n+ key: \"comments\".to_string(), \n+ label: \"All Comments\".to_string(), \n+ description: \"Find the best comment across all imported threads.\".to_string(), \n+ completion: format!(\"{}comments/\", prefix) \n+ },\n+ ]\n+ )\n+ })\n+ \n+\n+ \n+ .at(\"user_selection\")\n+ .edge(EdgePattern::Variable(\"username\"), \"user_profile\")\n+ .handler(|query, prefix, _ctx| {\n+ UIAction::guide(\n+ query.to_string(),\n+ \"User Profile Sorting\".to_string(),\n+ \"Enter a Reddit username to sort their content.\".to_string(),\n+ vec![\n+ GuideOption {\n+ key: \"popular\".to_string(),\n+ label: \"Popular Users\".to_string(),\n+ description: \"Browse well-known Reddit users.\".to_string(),\n+ completion: prefix.to_string(),\n+ },\n+ ]\n+ )\n+ })\n+ \n+ // === Subreddit sort types ===\n+ .at(\"subreddit_hot\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ .at(\"subreddit_top\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ .at(\"subreddit_new\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ .at(\"subreddit_comments\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ // === User profile ===\n+ .at(\"user_profile\")\n+ .handler(|_query, _prefix, ctx| {\n+ let username = ctx.get(\"username\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.user\".to_string(),\n+ pk: username,\n+ }\n+ })\n+ \n+ // === Build the graph ===\n+ .build()\n+}\n+\n+// --- Public API ---\n+\n+/// Parse a query string and return a UI action\n+pub fn parse_reddit_url(query: &str) -> UIAction {\n+ let graph = build_reddit_graph();\n+ graph.parse(query)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ /// Represents a keystroke action\n+ #[derive(Debug, Clone, PartialEq)]\n+ enum KeyAction {\n+ Type(String), // Type characters\n+ Tab, // Press tab (accept completion)\n+\n+ }\n+\n+ /// Expected state after a keystroke\n+ #[derive(Debug, Clone)]\n+ enum ExpectedAction {\n+ Suggestion { completion: String },\n+ ScrollingSuggestions { completions: Vec },\n+ Guide { title_contains: String },\n+\n+ RenderSubreddit { subreddit: String, sort: Option },\n+ RenderSubredditComments { subreddit: String },\n+ RenderUser { username: String },\n+ ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution\n+ Error { error_type: String },\n+ Multiple { expected_actions: Vec }, // Multiple responses with specific expectations\n+ MultipleAny, // Multiple responses (any sub-actions - legacy)\n+ DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions\n+\n+ }\n+\n+ impl ExpectedAction {\n+ fn matches(&self, action: &UIAction) -> bool {\n+ match (self, action) {\n+ (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {\n+ data.suggestion.as_ref()\n+ .map(|s| s.completion == *completion)\n+ .unwrap_or(false)\n+ }\n+ (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => {\n+ let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();\n+ *completions == actual_completions\n+ }\n+ (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => {\n+ title.contains(title_contains)\n+ }\n+ (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n+ UIAction::RenderEntityView { ns, pk }) => {\n+ ns == \"reddit.subreddit\" && pk == subreddit\n+ }\n+ (ExpectedAction::RenderSubredditComments { subreddit }, \n+ UIAction::RenderEntityView { ns, pk }) => {\n+ ns == \"reddit.subreddit\" && pk == subreddit\n+ }\n+ (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {\n+ ns == \"reddit.user\" && pk == username\n+ }\n+ (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n+ UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n+ s == subreddit && p == prefix\n+ }\n+ (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {\n+ data.error_type == *error_type\n+ }\n+ (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => {\n+ // Check that all expected actions are present\n+ if expected_actions.len() != actions.len() {\n+ return false;\n+ }\n+ expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {\n+ expected.matches(actual)\n+ })\n+ }\n+ (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true,\n+ (ExpectedAction::DbSuggestions { partial, prefix }, \n+ UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {\n+ p == partial && pr == prefix\n+ }\n+\n+ _ => false,\n+ }\n+ }\n+ }\n+\n+ /// Test helper to simulate a sequence of keystrokes\n+ fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> {\n+ let graph = build_reddit_graph();\n+ let mut current_text = String::new();\n+ let mut results = Vec::new();\n+ \n+ for action in actions {\n+ match action {\n+ KeyAction::Type(text) => {\n+ current_text.push_str(&text);\n+ let result = graph.parse(¤t_text);\n+ results.push((current_text.clone(), result));\n+ }\n+ KeyAction::Tab => {\n+ // Tab accepts the current suggestion if there is one\n+ let result = graph.parse(¤t_text);\n+ if let UIAction::ShowSuggestions(ref data) = result {\n+ if let Some(ref suggestion) = data.suggestion {\n+ current_text = suggestion.completion.clone();\n+ let new_result = graph.parse(¤t_text);\n+ results.push((current_text.clone(), new_result));\n+ }\n+ }\n+ }\n+\n+ }\n+ }\n+ \n+ results\n+ }\n+\n+ /// Test a flow using declarative (KeyAction, ExpectedAction) tuples\n+ fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {\n+ let graph = build_reddit_graph();\n+ let mut current_text = String::new();\n+ \n+ println!(\"\\n=== Flow: {} ===\", name);\n+ \n+ for (i, (key_action, expected)) in flow.iter().enumerate() {\n+ // Perform the keystroke\n+ match key_action {\n+ KeyAction::Type(text) => {\n+ current_text.push_str(text);\n+ }\n+ KeyAction::Tab => {\n+ // Tab accepts the current suggestion\n+ let result = graph.parse(¤t_text);\n+ if let UIAction::ShowSuggestions(data) = result {\n+ if let Some(suggestion) = &data.suggestion {\n+ current_text = suggestion.completion.clone();\n+ }\n+ }\n+ }\n+ }\n+ \n+ // Check the result\n+ let actual_action = graph.parse(¤t_text);\n+ \n+ println!(\" Step {}: {:?} -> '{}' -> {:?}\", \n+ i + 1, key_action, current_text, actual_action);\n+ \n+ assert!(\n+ expected.matches(&actual_action),\n+ \"Flow '{}' failed at step {}\\n Expected: {:?}\\n Actual: {:?}\\n Text: '{}'\",\n+ name, i + 1, expected, actual_action, current_text\n+ );\n+ }\n+ \n+ println!(\"✓ Flow '{}' passed!\", name);\n+ }\n+ \n+ /// Legacy helper for backward compatibility (will be removed)\n+ fn assert_flow(\n+ name: &str,\n+ actions: Vec,\n+ expected_checks: Vec bool>>,\n+ ) {\n+ let results = simulate_keystrokes(actions);\n+ \n+ println!(\"\\n=== Flow: {} ===\", name);\n+ for (i, (text, action)) in results.iter().enumerate() {\n+ println!(\" Step {}: '{}' -> {:?}\", i + 1, text, action);\n+ \n+ if i < expected_checks.len() {\n+ let check = &expected_checks[i];\n+ assert!(\n+ check(text, action),\n+ \"Flow '{}' failed at step {} with text '{}' and action {:?}\",\n+ name, i + 1, text, action\n+ );\n+ }\n+ }\n+ println!(\"✓ Flow '{}' passed!\", name);\n+ }\n+\n+ #[test]\n+ fn test_https_reddit_tab_flow() {\n+ // Test: typing \"https://reddit.com\" and pressing tab should give \"https://reddit.com/\"\n+ assert_flow(\n+ \"HTTPS Reddit with Tab\",\n+ vec![\n+ KeyAction::Type(\"https://reddit.com\".to_string()),\n+ KeyAction::Tab,\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // After typing \"https://reddit.com\", should get a suggestion\n+ text == \"https://reddit.com\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com/\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should have \"https://reddit.com/\" and show guide\n+ text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_quick_subreddit_flow() {\n+ // Test: \"r\" -> TAB -> \"rust\" (direct alias flow)\n+ assert_flow(\n+ \"Quick Subreddit Access\",\n+ vec![\n+ KeyAction::Type(\"r\".to_string()),\n+ KeyAction::Tab,\n+ KeyAction::Type(\"rust\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // \"r\" should suggest \"r/\"\n+ text == \"r\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"r/\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should have \"r/\" and show subreddit selection\n+ text == \"r/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"r/rust\" should resolve the subreddit\n+ text == \"r/rust\" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"rust\" && prefix == \"r/rust\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_progressive_completion_flow() {\n+ // Test progressive typing: \"r\" -> \"re\" -> \"red\" -> \"redd\" -> \"reddit\" -> TAB\n+ let progressive_actions = vec![\n+ KeyAction::Type(\"r\".to_string()),\n+ KeyAction::Type(\"e\".to_string()),\n+ KeyAction::Type(\"d\".to_string()),\n+ KeyAction::Type(\"d\".to_string()),\n+ KeyAction::Type(\"i\".to_string()),\n+ KeyAction::Type(\"t\".to_string()),\n+ KeyAction::Tab,\n+ ];\n+ \n+ let results = simulate_keystrokes(progressive_actions);\n+ \n+ println!(\"\\n=== Progressive Completion Flow ===\");\n+ for (i, (text, action)) in results.iter().enumerate() {\n+ println!(\" '{}' -> {:?}\", text, action);\n+ \n+ // First step: \"r\" should suggest \"r/\"\n+ if i == 0 && text == \"r\" {\n+ match action {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(\n+ data.suggestion.as_ref().unwrap().completion,\n+ \"r/\",\n+ \"Should suggest r/ at 'r'\"\n+ );\n+ }\n+ _ => panic!(\"Expected suggestion at 'r'\"),\n+ }\n+ }\n+ // Other steps before tab should suggest \"reddit.com\"\n+ else if i > 0 && i < results.len() - 1 {\n+ match action {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(\n+ data.suggestion.as_ref().unwrap().completion,\n+ \"reddit.com\",\n+ \"Should suggest reddit.com at '{}'\", text\n+ );\n+ }\n+ _ => panic!(\"Expected suggestion at '{}'\", text),\n+ }\n+ }\n+ }\n+ \n+ // After tab, should have \"reddit.com\"\n+ let (final_text, _) = results.last().unwrap();\n+ assert_eq!(final_text, \"reddit.com\");\n+ println!(\"✓ Progressive completion flow passed!\");\n+ }\n+\n+ #[test]\n+ fn test_subreddit_sort_flow() {\n+ // Test navigating to a subreddit and choosing a sort option\n+ assert_flow(\n+ \"Subreddit Sort Navigation\",\n+ vec![\n+ KeyAction::Type(\"reddit.com/r/programming/hot\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"reddit.com/r/programming/hot\" && \n+ matches!(action, UIAction::RenderEntityView { ns, pk } \n+ if ns == \"reddit.subreddit\" && pk == \"programming\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_user_profile_flow() {\n+ // Test navigating to a user profile\n+ assert_flow(\n+ \"User Profile Navigation\",\n+ vec![\n+ KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"reddit.com/u/spez\" && \n+ matches!(action, UIAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_alias_shortcut_flow() {\n+ // Test using the \"r/\" shortcut - now correctly goes directly to subreddit\n+ assert_flow(\n+ \"Alias Shortcut\",\n+ vec![\n+ KeyAction::Type(\"r/\".to_string()),\n+ KeyAction::Type(\"technology\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"r/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ text == \"r/technology\" && \n+ matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_www_prefix_flow() {\n+ // Test with www prefix\n+ assert_flow(\n+ \"WWW Prefix\",\n+ vec![\n+ KeyAction::Type(\"www.reddit.com\".to_string()),\n+ KeyAction::Tab,\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // Should suggest adding slash\n+ text == \"www.reddit.com\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"www.reddit.com/\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should show guide\n+ text == \"www.reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_invalid_path_handling() {\n+ // Test that invalid paths show errors\n+ assert_flow(\n+ \"Invalid Path\",\n+ vec![\n+ KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"reddit.com/invalid/path\" && \n+ matches!(action, UIAction::ShowError(_))\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_complete_user_journey() {\n+ // Test a complete user journey: type partial URL, tab complete, navigate to subreddit\n+ assert_flow(\n+ \"Complete User Journey\",\n+ vec![\n+ KeyAction::Type(\"http\".to_string()),\n+ KeyAction::Type(\"s://r\".to_string()),\n+ KeyAction::Tab,\n+ KeyAction::Type(\"/\".to_string()),\n+ KeyAction::Type(\"r/\".to_string()),\n+ KeyAction::Type(\"programming\".to_string()),\n+ KeyAction::Type(\"/\".to_string()),\n+ KeyAction::Type(\"top\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // \"http\" should suggest \"https://\"\n+ text == \"http\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"https://\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://r\" should suggest \"https://reddit.com\"\n+ text == \"https://r\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should have \"https://reddit.com\"\n+ text == \"https://reddit.com\" && matches!(action, UIAction::ShowSuggestions(_))\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/\" should show guide\n+ text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/\" should show subreddit selection\n+ text == \"https://reddit.com/r/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/programming\" should resolve subreddit\n+ text == \"https://reddit.com/r/programming\" && \n+ matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } \n+ if subreddit == \"programming\" && prefix == \"https://reddit.com/r/programming\")\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/programming/\" should show sort options\n+ text == \"https://reddit.com/r/programming/\" && \n+ matches!(action, UIAction::ShowStaticGuide { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/programming/top\" should render entity view\n+ text == \"https://reddit.com/r/programming/top\" && \n+ matches!(action, UIAction::RenderEntityView { ns, pk } \n+ if ns == \"reddit.subreddit\" && pk == \"programming\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_multiple_tab_completions() {\n+ // Test multiple tab completions in sequence\n+ let actions = vec![\n+ KeyAction::Type(\"h\".to_string()),\n+ KeyAction::Tab, // Complete to \"https://\"\n+ KeyAction::Type(\"r\".to_string()),\n+ KeyAction::Tab, // Complete to \"https://reddit.com\"\n+ KeyAction::Type(\"/\".to_string()),\n+ ];\n+ \n+ let results = simulate_keystrokes(actions);\n+ \n+ println!(\"\\n=== Multiple Tab Completions ===\");\n+ for (i, (text, _action)) in results.iter().enumerate() {\n+ println!(\" Step {}: '{}'\", i + 1, text);\n+ }\n+ \n+ // Verify the final state\n+ assert_eq!(results[1].0, \"https://\"); // After first tab\n+ assert_eq!(results[3].0, \"https://reddit.com\"); // After second tab\n+ assert_eq!(results[4].0, \"https://reddit.com/\"); // After typing /\n+ \n+ println!(\"✓ Multiple tab completions work correctly!\");\n+ }\n+\n+ // === CONVENIENCE MACROS FOR CLEANER TESTS ===\n+ \n+ macro_rules! flow {\n+ ($(($key:expr, $expected:expr)),* $(,)?) => {\n+ vec![$(($key, $expected)),*]\n+ };\n+ }\n+ \n+ macro_rules! type_text {\n+ ($text:expr) => {\n+ KeyAction::Type($text.to_string())\n+ };\n+ }\n+ \n+ macro_rules! suggests {\n+ ($completion:expr) => {\n+ ExpectedAction::Suggestion { completion: $completion.to_string() }\n+ };\n+ }\n+ \n+ macro_rules! renders_subreddit {\n+ ($subreddit:expr) => {\n+ ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }\n+ };\n+ ($subreddit:expr, $sort:expr) => {\n+ ExpectedAction::RenderSubreddit { \n+ subreddit: $subreddit.to_string(), \n+ sort: Some($sort.to_string()) \n+ }\n+ };\n+ }\n+ \n+ macro_rules! renders_subreddit_comments {\n+ ($subreddit:expr) => {\n+ ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }\n+ };\n+ }\n+ \n+ macro_rules! resolves_subreddit {\n+ ($subreddit:expr, $prefix:expr) => {\n+ ExpectedAction::ResolveSubreddit { \n+ subreddit: $subreddit.to_string(),\n+ prefix: $prefix.to_string()\n+ }\n+ };\n+ }\n+ \n+ macro_rules! shows_guide {\n+ ($title_contains:expr) => {\n+ ExpectedAction::Guide { title_contains: $title_contains.to_string() }\n+ };\n+ }\n+ \n+ macro_rules! multiple {\n+ ($($action:expr),* $(,)?) => {\n+ ExpectedAction::Multiple { expected_actions: vec![$($action),*] }\n+ };\n+ }\n+ \n+ macro_rules! scrolling_suggestions {\n+ ($($completion:expr),* $(,)?) => {\n+ ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }\n+ };\n+ }\n+ \n+ macro_rules! db_suggestions {\n+ ($partial:expr, $prefix:expr) => {\n+ ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }\n+ };\n+ }\n+\n+ // === NEW DECLARATIVE TESTS ===\n+\n+ #[test]\n+ fn test_declarative_https_tab_flow() {\n+ test_flow(\"HTTPS Tab Completion\", vec![\n+ (KeyAction::Type(\"https://reddit.com\".to_string()), \n+ ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n+ (KeyAction::Tab, \n+ multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"),\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_quick_subreddit_flow() {\n+ test_flow(\"Quick Subreddit Flow\", vec![\n+ (KeyAction::Type(\"r\".to_string()), \n+ ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n+ (KeyAction::Tab, \n+ ExpectedAction::MultipleAny), // r/ shows subreddit selection\n+ (KeyAction::Type(\"rust\".to_string()),\n+ ExpectedAction::ResolveSubreddit { subreddit: \"rust\".to_string(), prefix: \"r/rust\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_complete_journey() {\n+ test_flow(\"Complete User Journey\", vec![\n+ (KeyAction::Type(\"http\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"https://\".to_string() }),\n+ (KeyAction::Type(\"s://r\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"https://reddit.com\".to_string() }),\n+ (KeyAction::Tab,\n+ ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n+ (KeyAction::Type(\"/r/programming/top\".to_string()),\n+ ExpectedAction::RenderSubreddit { \n+ subreddit: \"programming\".to_string(), \n+ sort: Some(\"top\".to_string()) \n+ }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_user_profile() {\n+ test_flow(\"User Profile Navigation\", vec![\n+ (KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n+ ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_error_handling() {\n+ test_flow(\"Error Handling\", vec![\n+ (KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n+ ExpectedAction::Error { error_type: \"InvalidPath\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_progressive_completion() {\n+ test_flow(\"Progressive Completion\", vec![\n+ (KeyAction::Type(\"r\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n+ (KeyAction::Type(\"e\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n+ (KeyAction::Type(\"d\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n+ (KeyAction::Type(\"dit\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n+ (KeyAction::Tab,\n+ ExpectedAction::Suggestion { completion: \"reddit.com/\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_subreddit_guide() {\n+ test_flow(\"Subreddit Guide\", vec![\n+ (KeyAction::Type(\"reddit.com/r/programming/\".to_string()),\n+ ExpectedAction::Guide { title_contains: \"What to sort\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_clean_macro_example() {\n+ // This is what the tests can look like with macros!\n+ test_flow(\"Clean Macro Example\", flow![\n+ (type_text!(\"r\"), suggests!(\"r/\")),\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ (type_text!(\"rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_unified_subreddit_resolution_flow() {\n+ // This test demonstrates the new unified behavior:\n+ // - r/programming (exact) → tries exact match first\n+ // - r/pro (partial) → tries exact match, then suggestions + TAB completion\n+ \n+ test_flow(\"Unified Resolution: Exact Match\", flow![\n+ (type_text!(\"r/programming\"), resolves_subreddit!(\"programming\", \"r/programming\")),\n+ ]);\n+ \n+ test_flow(\"Unified Resolution: Partial Match\", flow![\n+ (type_text!(\"r/pro\"), resolves_subreddit!(\"pro\", \"r/pro\")),\n+ ]);\n+ \n+ // Both go through the same action type, but dispatcher handles them differently:\n+ // - If \"programming\" exists in DB → shows EntityView immediately\n+ // - If \"pro\" doesn't exist in DB → shows Multiple with:\n+ // 1. Suggestions (for TAB completion to best match) \n+ // 2. Selection (for clickable options including import)\n+ }\n+\n+ #[test]\n+ fn test_tab_completion_workflow_unified() {\n+ // This demonstrates the desired TAB completion behavior:\n+ // r/pro + TAB → r/programming (if \"programming\" is the best DB match)\n+ \n+ // Note: This test shows the PARSER behavior. The actual TAB completion \n+ // happens in the frontend when it receives the Multiple response containing\n+ // both Suggestions (for TAB) and Selection (for click options).\n+ \n+ let graph = build_reddit_graph();\n+ \n+ // 1. Parser generates unified action for partial input\n+ match graph.parse(\"r/pro\") {\n+ UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n+ assert_eq!(subreddit, \"pro\");\n+ assert_eq!(prefix, \"r/pro\");\n+ println!(\"✓ Parser correctly identifies 'r/pro' as subreddit resolution\");\n+ }\n+ _ => panic!(\"Expected ResolveAndDisplaySubreddit for 'r/pro'\"),\n+ }\n+ \n+ // 2. When dispatcher runs (in real app), it will return Multiple response with:\n+ // - Suggestions: { completion: \"r/programming\" } (for TAB)\n+ // - Selection: [ \"Import r/pro\", \"r/programming\", ... ] (for clicks)\n+ \n+ println!(\"✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)\");\n+ }\n+\n+ #[test]\n+ fn test_ultra_clean_user_journey() {\n+ test_flow(\"Ultra Clean User Journey\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ (type_text!(\"ttps://r\"), suggests!(\"https://reddit.com\")), \n+ (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n+ (type_text!(\"/u/spez\"), ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n+ ]);\n+ }\n+\n+ // === TESTS FROM parser.tdsl ===\n+\n+ #[test]\n+ fn test_tdsl_basic_reddit_progression() {\n+ // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps\n+ test_flow(\"TDSL Basic Reddit Progression\", flow![\n+ (type_text!(\"r\"), suggests!(\"r/\")),\n+ (type_text!(\"e\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"d\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"d\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"i\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"t\"), suggests!(\"reddit.com\")),\n+ (type_text!(\".\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"c\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"o\"), suggests!(\"reddit.com\")),\n+ (KeyAction::Tab, suggests!(\"reddit.com/\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_reddit_com_slash_infographic() {\n+ // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}\n+ test_flow(\"TDSL Reddit.com/ Infographic\", flow![\n+ (type_text!(\"reddit.com/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_selection() {\n+ // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n+ test_flow(\"TDSL Subreddit Selection\", flow![\n+ (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_view() {\n+ // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}\n+ test_flow(\"TDSL Subreddit View\", flow![\n+ (type_text!(\"reddit.com/r/programming\"), resolves_subreddit!(\"programming\", \"reddit.com/r/programming\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_slash_infographic() {\n+ // reddit.com/r/{sub}/ -> {show infographic or something}\n+ test_flow(\"TDSL Subreddit Slash Infographic\", flow![\n+ (type_text!(\"reddit.com/r/programming/\"), shows_guide!(\"What to sort\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_comments() {\n+ // reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n+ test_flow(\"TDSL Subreddit Comments\", flow![\n+ (type_text!(\"reddit.com/r/programming/comments\"), renders_subreddit_comments!(\"programming\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_h_to_https() {\n+ // h->https:// (show supported domains)\n+ test_flow(\"TDSL H to HTTPS\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_composable_https_reddit() {\n+ // https://r->https://reddit.com/\n+ test_flow(\"TDSL Composable HTTPS Reddit\", flow![\n+ (type_text!(\"https://r\"), suggests!(\"https://reddit.com\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_composable_https_www() {\n+ // https://w->https://www.\n+ test_flow(\"TDSL Composable HTTPS WWW\", flow![\n+ (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_composable_https_www_reddit() {\n+ // https://www.r->https://www.reddit.com/\n+ test_flow(\"TDSL Composable HTTPS WWW Reddit\", flow![\n+ (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_full_composable_chain() {\n+ // Complete chain showing composability: h -> https:// -> https://www.reddit.com\n+ test_flow(\"TDSL Full Composable Chain Step 1\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Full Composable Chain Step 2\", flow![\n+ (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Full Composable Chain Step 3\", flow![\n+ (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_progressive_reddit_paths() {\n+ // Test various reddit paths work as expected\n+ test_flow(\"TDSL Progressive Reddit Paths\", flow![\n+ (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test] \n+ fn test_tdsl_subreddit_sorting_options() {\n+ // Test that subreddit sorting options work as described\n+ test_flow(\"TDSL Subreddit Sorting\", flow![\n+ (type_text!(\"reddit.com/r/rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Subreddit Top\", flow![\n+ (type_text!(\"reddit.com/r/rust/top\"), renders_subreddit!(\"rust\", \"top\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Subreddit New\", flow![\n+ (type_text!(\"reddit.com/r/rust/new\"), renders_subreddit!(\"rust\", \"new\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_all_reddit_prefixes() {\n+ // Test all the prefixes mentioned in parser.tdsl work\n+ // \"r\" now suggests \"r/\", all others suggest \"reddit.com\"\n+ let prefixes_to_reddit = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n+ \n+ // Test \"r\" separately since it now suggests \"r/\"\n+ test_flow(\"TDSL Prefix: r\", flow![\n+ (type_text!(\"r\"), suggests!(\"r/\")),\n+ ]);\n+ \n+ for prefix in prefixes_to_reddit {\n+ test_flow(&format!(\"TDSL Prefix: {}\", prefix), flow![\n+ (type_text!(prefix), suggests!(\"reddit.com\")),\n+ ]);\n+ }\n+ }\n+\n+ #[test]\n+ fn test_tdsl_protocol_combinations() {\n+ // Test various protocol combinations from parser.tdsl\n+ let test_cases = vec![\n+ (\"http://r\", \"http://reddit.com\"),\n+ (\"https://r\", \"https://reddit.com\"),\n+ (\"www.r\", \"www.reddit.com\"),\n+ (\"https://www.r\", \"https://www.reddit.com\"),\n+ (\"http://www.r\", \"http://www.reddit.com\"),\n+ ];\n+ \n+ for (input, expected) in test_cases {\n+ test_flow(&format!(\"TDSL Protocol: {}\", input), flow![\n+ (type_text!(input), suggests!(expected)),\n+ ]);\n+ }\n+ }\n+\n+ #[test]\n+ fn test_tdsl_edge_case_completions() {\n+ // Test edge cases mentioned in parser.tdsl\n+ test_flow(\"TDSL Reddit.com completion\", flow![\n+ (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n+ ]);\n+ \n+ // Test that typing full reddit.com suggests the slash\n+ test_flow(\"TDSL Full domain completion\", flow![\n+ (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_real_user_trace_2025_08_07_fixed() {\n+ // Based on actual WebSocket trace from 2025-08-07T01:35:46Z\n+ // This test shows the CORRECT behavior after fixing the protocol preservation bug\n+ test_flow(\"Real User Trace: Progressive Typing with Tab Completions (Fixed)\", flow![\n+ // User started typing \"h\"\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ \n+ // User continued to \"ht\" \n+ (type_text!(\"t\"), suggests!(\"https://\")),\n+ \n+ // User finished typing \"https://\" (trace shows full protocol)\n+ (type_text!(\"tps://\"), suggests!(\"https://\")),\n+ \n+ // User started typing \"r\" after protocol\n+ (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n+ \n+ // User continued typing \"re\"\n+ (type_text!(\"e\"), suggests!(\"https://reddit.com\")),\n+ \n+ // User typed out or completed \"https://reddit.com\"\n+ (type_text!(\"ddit.com\"), suggests!(\"https://reddit.com/\")),\n+ \n+ // User accepted completion to \"https://reddit.com/\"\n+ // NOW suggestions should preserve the https:// protocol\n+ (KeyAction::Tab, multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // This is the key fix!\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_protocol_preservation_bug_fix() {\n+ // This test specifically verifies the fix for the protocol preservation bug\n+ test_flow(\"Protocol Preservation: HTTPS Reddit Homepage\", flow![\n+ (type_text!(\"https://reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // Should preserve https://\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ \n+ // Test that subreddit selection preserves protocol\n+ test_flow(\"Protocol Preservation: HTTPS Subreddit Selection\", flow![\n+ (type_text!(\"https://reddit.com/r/\"), multiple![\n+ 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://\n+ db_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/r/\")\n+ ]),\n+ ]);\n+ \n+ // Test with different protocols\n+ test_flow(\"Protocol Preservation: HTTP\", flow![\n+ (type_text!(\"http://reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"http://reddit.com/r/\", \"http://reddit.com/u/\"), // Should preserve http://\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ \n+ test_flow(\"Protocol Preservation: WWW\", flow![\n+ (type_text!(\"www.reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"www.reddit.com/r/\", \"www.reddit.com/u/\"), // Should preserve www.\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ \n+ // Test that plain reddit.com still works\n+ test_flow(\"Protocol Preservation: Plain Domain\", flow![\n+ (type_text!(\"reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"reddit.com/r/\", \"reddit.com/u/\"), // No protocol prefix\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_user_navigation_pattern() {\n+ // Models how users actually navigate: type → tab → click suggestion → end up at destination\n+ // This captures the \"jump\" from https://reddit.com/ to reddit.com/r/ seen in the trace\n+ test_flow(\"User Navigation: Protocol to Domain\", flow![\n+ // User types and gets to homepage\n+ (type_text!(\"https://reddit.com/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ \n+ // Then they navigate (perhaps clicking a suggestion) to subreddit selection\n+ test_flow(\"User Navigation: Click to Subreddit\", flow![\n+ // This is where they ended up - the suggestion in the guide probably said \"reddit.com/r/\"\n+ (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_progressive_typing_pattern() {\n+ // Based on the trace pattern - users often type character by character\n+ // This tests the exact sequence of suggestions they would see\n+ test_flow(\"Progressive Typing Pattern\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ (type_text!(\"t\"), suggests!(\"https://\")), // Still suggests https://\n+ (type_text!(\"t\"), suggests!(\"https://\")), // ht -> htt, still suggests https://\n+ (type_text!(\"p\"), suggests!(\"https://\")), // http should still suggest https://\n+ (type_text!(\"s\"), suggests!(\"https://\")), // https should suggest https://\n+ (type_text!(\"://\"), suggests!(\"https://\")), // Even complete protocol still suggests itself\n+ ]);\n+ }\n+\n+ #[test] \n+ fn test_tab_completion_workflow() {\n+ // Test what happens when user uses tab completions strategically\n+ test_flow(\"Strategic Tab Completion Workflow\", flow![\n+ // Start typing, get suggestion\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ \n+ // Accept suggestion with tab - this should move us to \"https://\"\n+ (KeyAction::Tab, suggests!(\"https://\")), // After tab, we're at \"https://\" which still suggests itself\n+ \n+ // Start typing reddit\n+ (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n+ \n+ // Accept reddit suggestion \n+ (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n+ \n+ // Accept final suggestion to get to homepage\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ // Keep the original tests as well\n+ #[test]\n+ fn test_reddit_prefix_autocomplete() {\n+ let graph = build_reddit_graph();\n+ \n+ // \"r\" now suggests \"r/\", others suggest \"reddit.com\"\n+ match graph.parse(\"r\") {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(data.suggestion.as_ref().unwrap().completion, \"r/\");\n+ println!(\"✓ 'r' → r/\");\n+ }\n+ _ => panic!(\"Expected suggestion for 'r'\"),\n+ }\n+ \n+ // Test other prefixes that should suggest \"reddit.com\"\n+ let prefixes = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n+ \n+ for prefix in prefixes {\n+ match graph.parse(prefix) {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(data.suggestion.as_ref().unwrap().completion, \"reddit.com\");\n+ println!(\"✓ '{}' → reddit.com\", prefix);\n+ }\n+ _ => panic!(\"Expected suggestion for '{}'\", prefix),\n+ }\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_protocol_composition() {\n+ let graph = build_reddit_graph();\n+ \n+ // Test protocol + reddit compositions\n+ let tests = vec![\n+ (\"https://r\", \"https://reddit.com\"),\n+ (\"https://re\", \"https://reddit.com\"),\n+ (\"https://reddit\", \"https://reddit.com\"),\n+ (\"https://www.r\", \"https://www.reddit.com\"),\n+ (\"https://www.reddit\", \"https://www.reddit.com\"),\n+ (\"http://r\", \"http://reddit.com\"),\n+ (\"www.r\", \"www.reddit.com\"),\n+ ];\n+ \n+ for (input, expected) in tests {\n+ match graph.parse(input) {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);\n+ println!(\"✓ '{}' → {}\", input, expected);\n+ }\n+ _ => panic!(\"Expected suggestion for '{}'\", input),\n+ }\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_alias_and_full_paths() {\n+ let graph = build_reddit_graph();\n+ \n+ // reddit.com/ should show guide, r/ should show subreddit selection\n+ match graph.parse(\"reddit.com/\") {\n+ UIAction::ShowMultiple { actions } => {\n+ let has_guide = actions.iter()\n+ .any(|a| matches!(a, UIAction::ShowStaticGuide { .. }));\n+ assert!(has_guide, \"Path 'reddit.com/' should show guide\");\n+ println!(\"✓ 'reddit.com/' shows Reddit root guide\");\n+ }\n+ _ => panic!(\"Expected Multiple action for 'reddit.com/'\"),\n+ }\n+ \n+ match graph.parse(\"r/\") {\n+ UIAction::ShowMultiple { actions } => {\n+ let has_scrolling_suggestions = actions.iter()\n+ .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));\n+ let has_db_suggestions = actions.iter()\n+ .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. }));\n+ assert!(has_scrolling_suggestions && has_db_suggestions, \n+ \"Path 'r/' should show scrolling suggestions and DB suggestions\");\n+ println!(\"✓ 'r/' shows subreddit selection\");\n+ }\n+ _ => panic!(\"Expected Multiple action for 'r/'\"),\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_deep_navigation() {\n+ let graph = build_reddit_graph();\n+ \n+ // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit\n+ match graph.parse(\"reddit.com/r/rust\") {\n+ UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n+ assert_eq!(subreddit, \"rust\");\n+ assert_eq!(prefix, \"reddit.com/r/rust\");\n+ println!(\"✓ reddit.com/r/rust recognized\");\n+ }\n+ _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit\"),\n+ }\n+ \n+ // Test with alias (r/ goes directly to subreddit, no double r/)\n+ match graph.parse(\"r/programming\") {\n+ UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n+ assert_eq!(subreddit, \"programming\");\n+ assert_eq!(prefix, \"r/programming\");\n+ println!(\"✓ r/programming (alias) recognized\");\n+ }\n+ _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit via alias\"),\n+ }\n+ }\n+}\ndiff --git a/parser.tdsl b/parser.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..03ba9563ea8c73b7411cd1aa42dda9c7c313cd39\n--- /dev/null\n+++ b/parser.tdsl\n@@ -0,0 +1,32 @@\n+The parser is a series of edges/transitions, for example:\n+r -> reddit.com/ \n+where -> means that all of\n+ r->reddit.com/\n+ re->reddit.com/\n+ red->reddit.com/\n+ redd->reddit.com/\n+ reddi->reddit.com/\n+ reddit->reddit.com/\n+ reddit.->reddit.com/\n+ reddit.c->reddit.com/\n+ reddit.co->reddit.com/\n+ reddit.com->reddt.com/\n+ are defined as autocomplete suggestions\n+\n+re -> reddit.com/\n+reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/\n+reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n+reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/\n+reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n+\n+\n+h->https:// (show supported domains)\n+\n+// the edges are composable such that this is possible, while reusing the above reddit code.\n+https://r->https://reddit.com/\n+https://w->https://www.\n+https://www.r->https://www.reddit.com/\n+\n+\n+// and i also want to have commands after you press space, like\n+reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% \ndiff --git a/ranking.rs b/ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..34241cf6bf13117b3ad3262761eb7f7b48b54d80\n--- /dev/null\n+++ b/ranking.rs\n@@ -0,0 +1,393 @@\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/ranking.rs \n+use std::collections::{HashMap, HashSet};\n+\n+use crate::path_types::ItemId;\n+use crate::reducer::GroupState;\n+\n+#[derive(Debug, Clone)]\n+pub struct RankedItem {\n+ pub item: ItemId,\n+ pub score: f64,\n+}\n+\n+/// Compute connected components over the voted-pairs graph (treated as undirected).\n+///\n+/// Returns:\n+/// - `components`: each component is a sorted list of node indices, excluding isolates.\n+/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).\n+pub fn connected_components_from_voted_pairs(\n+ n: usize,\n+ voted_pairs: impl Iterator,\n+) -> (Vec>, Vec) {\n+ let mut adj: Vec> = vec![Vec::new(); n];\n+ for (a, b) in voted_pairs {\n+ if a >= n || b >= n || a == b {\n+ continue;\n+ }\n+ adj[a].push(b);\n+ adj[b].push(a);\n+ }\n+\n+ let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();\n+ isolates.sort();\n+\n+ let mut seen = vec![false; n];\n+ for &i in &isolates {\n+ seen[i] = true;\n+ }\n+\n+ let mut comps: Vec> = Vec::new();\n+ for i in 0..n {\n+ if seen[i] {\n+ continue;\n+ }\n+ let mut stack = vec![i];\n+ seen[i] = true;\n+ let mut comp: Vec = Vec::new();\n+ while let Some(x) = stack.pop() {\n+ comp.push(x);\n+ for &y in &adj[x] {\n+ if !seen[y] {\n+ seen[y] = true;\n+ stack.push(y);\n+ }\n+ }\n+ }\n+ comp.sort();\n+ comps.push(comp);\n+ }\n+\n+ (comps, isolates)\n+}\n+\n+/// Compute rank centrality scores for a GroupState.\n+/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing\n+/// an O(E) multiply per iteration.\n+pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {\n+ if !group.dirty && !group.cached_scores.is_empty() {\n+ return;\n+ }\n+\n+ let n = group.idx_to_item.len();\n+ if n == 0 {\n+ group.cached_scores = vec![];\n+ group.dirty = false;\n+ return;\n+ }\n+ if n == 1 {\n+ group.cached_scores = vec![1.0];\n+ group.dirty = false;\n+ return;\n+ }\n+\n+ let scores = compute_scores_from_edges(\n+ n,\n+ group.edges.iter().map(|(&k, &w)| (k, w)),\n+ max_iters,\n+ tol,\n+ );\n+ group.cached_scores = scores;\n+ group.dirty = false;\n+}\n+\n+pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {\n+ compute_group_ranking(group, max_iters, tol);\n+ let mut items: Vec = group\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, item)| RankedItem {\n+ item: item.clone(),\n+ score: *group.cached_scores.get(i).unwrap_or(&0.0),\n+ })\n+ .collect();\n+\n+ items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+ items\n+}\n+\n+fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {\n+ if n == 0 {\n+ return vec![];\n+ }\n+ if n == 1 {\n+ return vec![1.0];\n+ }\n+\n+ // Collect raw edges into a map for pairwise normalization.\n+ let mut raw: HashMap<(usize, usize), f64> = HashMap::new();\n+ for ((src, dst), w) in edges {\n+ if src >= n || dst >= n || w <= 0.0 {\n+ continue;\n+ }\n+ *raw.entry((src, dst)).or_insert(0.0) += w;\n+ }\n+\n+ // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).\n+ // This ensures repeated votes on the same pair don't inflate influence\n+ // beyond what the ratio implies.\n+ let keys: Vec<(usize, usize)> = raw.keys().copied().collect();\n+ let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();\n+ for (i, j) in keys {\n+ if normalized.contains_key(&(i, j)) {\n+ continue;\n+ }\n+ let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);\n+ let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);\n+ let total = w_ij + w_ji;\n+ if total <= 0.0 {\n+ continue;\n+ }\n+ normalized.insert((i, j), w_ij / total);\n+ if w_ji > 0.0 {\n+ normalized.insert((j, i), w_ji / total);\n+ }\n+ }\n+\n+ // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):\n+ // P_ij = (1/d_max) * A_ij for i ≠ j compared\n+ // P_ii = 1 - (1/d_max) * Σ_k A_ik\n+ // where d_i is the *degree* (number of distinct neighbors compared) and\n+ // d_max = max_i d_i. Using the unweighted degree — not the sum of\n+ // pairwise-normalized weights — is what guarantees aperiodicity: it\n+ // forces P_ii > 0 for every non-maximum-degree node, and for max-degree\n+ // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+ // loss). Without this, regular comparison graphs (e.g. a pure star at\n+ // ratio 2:1) produce a bipartite chain that oscillates instead of\n+ // converging — see issue #146.\n+ let mut out_edges: Vec> = vec![Vec::new(); n];\n+ let mut neighbors: Vec> = vec![HashSet::new(); n];\n+\n+ for ((src, dst), w) in &normalized {\n+ out_edges[*src].push((*dst, *w));\n+ neighbors[*src].insert(*dst);\n+ neighbors[*dst].insert(*src);\n+ }\n+\n+ let weight_sum: Vec = out_edges\n+ .iter()\n+ .map(|es| es.iter().map(|(_, w)| *w).sum())\n+ .collect();\n+ let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+ if d_max == 0 {\n+ return vec![1.0 / n as f64; n];\n+ }\n+ let d_max_f = d_max as f64;\n+\n+ let mut scores = vec![1.0 / n as f64; n];\n+ let mut next = vec![0.0f64; n];\n+\n+ for _ in 0..max_iters {\n+ next.fill(0.0);\n+ for i in 0..n {\n+ let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;\n+ next[i] += scores[i] * stay_prob;\n+\n+ if out_edges[i].is_empty() {\n+ continue;\n+ }\n+ for &(dst, w) in &out_edges[i] {\n+ next[dst] += scores[i] * (w / d_max_f);\n+ }\n+ }\n+\n+ let diff: f64 = scores\n+ .iter()\n+ .zip(next.iter())\n+ .map(|(a, b)| (a - b).abs())\n+ .sum();\n+\n+ scores.clone_from_slice(&next);\n+ if diff < tol {\n+ break;\n+ }\n+ }\n+\n+ let sum: f64 = scores.iter().sum();\n+ if sum.is_finite() && sum > 0.0 {\n+ for s in &mut scores {\n+ *s /= sum;\n+ }\n+ }\n+ scores\n+}\n+\n+/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.\n+///\n+/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.\n+pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {\n+ if idxs.is_empty() {\n+ return vec![];\n+ }\n+\n+ // Map original idx -> compact idx [0..m)\n+ let mut map: HashMap = HashMap::with_capacity(idxs.len());\n+ for (j, &i) in idxs.iter().enumerate() {\n+ map.insert(i, j);\n+ }\n+\n+ let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {\n+ let s = *map.get(&src)?;\n+ let d = *map.get(&dst)?;\n+ Some(((s, d), w))\n+ });\n+\n+ let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);\n+\n+ // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).\n+ let mut items: Vec = idxs\n+ .iter()\n+ .enumerate()\n+ .filter_map(|(j, &orig)| {\n+ let item = group.idx_to_item.get(orig)?.clone();\n+ Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })\n+ })\n+ .collect();\n+\n+ items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+ items\n+}\n+\n+pub fn group_summary_scores(\n+ group: &mut GroupState,\n+ max_iters: usize,\n+ tol: f64,\n+) -> HashMap {\n+ ranked_items(group, max_iters, tol)\n+ .into_iter()\n+ .map(|r| (r.item, r.score))\n+ .collect()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use crate::reducer::VoteData;\n+\n+ fn mk_group() -> GroupState {\n+ GroupState::new()\n+ }\n+\n+ fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+ use crate::path_types::ItemId;\n+ VoteData {\n+ ts,\n+ a: ItemId::parse(a).unwrap(),\n+ b: ItemId::parse(b).unwrap(),\n+ ratio_left: l,\n+ ratio_right: r,\n+ body: \"because\".to_string(),\n+ principal: \"test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n+ thread_tag: \"untagged\".to_string(),\n+ }\n+ }\n+\n+ /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n+ /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+ /// chain was bipartite; power iteration oscillated and returned the\n+ /// uniform initial distribution after an even number of steps. Using the\n+ /// paper's degree-based d_max gives every node a positive self-loop and\n+ /// the chain converges to the correct stationary distribution.\n+ #[test]\n+ fn star_topology_winner_at_top_via_subset() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+ let mut items: Vec<(usize, String)> = g\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, it)| (i, it.as_str().to_string()))\n+ .collect();\n+ items.sort_by(|a, b| a.1.cmp(&b.1));\n+ let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+ let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+ for r in &ranked {\n+ eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+ }\n+ assert_eq!(\n+ ranked[0].item.as_str(),\n+ \"https://slug.social/zebra\",\n+ \"zebra won both votes and should rank #1\"\n+ );\n+ }\n+\n+ #[test]\n+ fn group_ranking_cache_dirty_flow() {\n+ let mut g = mk_group();\n+ assert!(g.dirty);\n+\n+ g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+ assert!(g.dirty);\n+ assert!(g.cached_scores.is_empty());\n+\n+ compute_group_ranking(&mut g, 10000, 1e-8);\n+ assert!(!g.dirty);\n+ assert_eq!(g.cached_scores.len(), g.idx_to_item.len());\n+\n+ // Recomputing when not dirty should be a no-op.\n+ let before = g.cached_scores.clone();\n+ compute_group_ranking(&mut g, 10000, 1e-8);\n+ assert_eq!(before, g.cached_scores);\n+ }\n+\n+ #[test]\n+ fn connected_components_split_disconnected_pairs() {\n+ let mut g = mk_group();\n+ // Two disconnected edges: (a,b) and (c,d)\n+ g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+ g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n+\n+ let n = g.idx_to_item.len();\n+ let (mut comps, isolates) =\n+ connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());\n+ assert!(isolates.is_empty());\n+ // Order-independent: sort components by their item names for stable assert.\n+ comps.sort_by_key(|c| {\n+ c.iter()\n+ .map(|&i| g.idx_to_item[i].clone())\n+ .collect::>()\n+ });\n+ assert_eq!(comps.len(), 2);\n+ let comp0 = comps[0]\n+ .iter()\n+ .map(|&i| g.idx_to_item[i].as_str())\n+ .collect::>();\n+ let comp1 = comps[1]\n+ .iter()\n+ .map(|&i| g.idx_to_item[i].as_str())\n+ .collect::>();\n+ assert_eq!(comp0, vec![\"https://slug.social/a\", \"https://slug.social/b\"]);\n+ assert_eq!(comp1, vec![\"https://slug.social/c\", \"https://slug.social/d\"]);\n+ }\n+\n+ #[test]\n+ fn subset_ranking_ranks_within_component_only() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n+ g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n+\n+ let (comps, _) =\n+ connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());\n+ assert_eq!(comps.len(), 2);\n+\n+ // Rank each component and ensure winner is first within that component.\n+ for comp in comps {\n+ let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);\n+ assert_eq!(ranked.len(), 2);\n+ let names = ranked.iter().map(|r| r.item.as_str()).collect::>();\n+ if names.contains(&\"https://slug.social/a\") {\n+ assert_eq!(names[0], \"https://slug.social/a\");\n+ } else {\n+ assert_eq!(names[0], \"https://slug.social/d\");\n+ }\n+ }\n+ }\n+}\n+\n+\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ \n+\ndiff --git a/reducer.rs b/reducer.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..1ba8030feaf783b066b2e0bba4d2d05659c992f4\n--- /dev/null\n+++ b/reducer.rs\n@@ -0,0 +1,845 @@\n+use std::collections::{HashMap, HashSet, VecDeque};\n+\n+use serde::{Deserialize, Serialize};\n+\n+use crate::canonical_path::canonicalize_tag;\n+use crate::dsl;\n+use crate::events::{Event, Ingest, ThreadCapability};\n+use crate::path_types::ItemId;\n+\n+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n+pub enum ScopeId {\n+ Public,\n+ Room(String),\n+}\n+\n+/// Wire `room` field → content scope (`\"public\"` → [`ScopeId::Public`]).\n+pub fn scope_from_room_wire(room: &str) -> ScopeId {\n+ let r = room.trim();\n+ if r.is_empty() || r == \"public\" {\n+ ScopeId::Public\n+ } else {\n+ ScopeId::Room(r.to_string())\n+ }\n+}\n+\n+/// Parsed vote data (internal representation).\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct VoteData {\n+ pub ts: i64,\n+ pub a: ItemId,\n+ pub b: ItemId,\n+ pub ratio_left: i32,\n+ pub ratio_right: i32,\n+ pub body: String,\n+ pub principal: String,\n+ pub delegate: Option,\n+ /// Forum channel where this vote was cast (tag only, not room id).\n+ pub thread_tag: String,\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct GroupState {\n+ pub item_to_idx: HashMap,\n+ pub idx_to_item: Vec,\n+\n+ /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.\n+ pub edges: HashMap<(usize, usize), f64>,\n+\n+ /// Unordered pairs that have at least one vote recorded between them (i,\n+ pub dirty: bool,\n+ pub cached_scores: Vec,\n+ pub recent_votes: VecDeque,\n+}\n+\n+impl Default for GroupState {\n+ fn default() -> Self {\n+ Self::new()\n+ }\n+}\n+\n+impl GroupState {\n+ pub fn new() -> Self {\n+ Self {\n+ item_to_idx: HashMap::new(),\n+ idx_to_item: Vec::new(),\n+ edges: HashMap::new(),\n+ voted_pairs: HashSet::new(),\n+ dirty: true,\n+ cached_scores: Vec::new(),\n+ recent_votes: VecDeque::with_capacity(200),\n+ }\n+ }\n+\n+ fn ensure_item(&mut self, item: &ItemId) -> usize {\n+ if let Some(&idx) = self.item_to_idx.get(item) {\n+ return idx;\n+ }\n+ let idx = self.idx_to_item.len();\n+ self.idx_to_item.push(item.clone());\n+ self.item_to_idx.insert(item.clone(), idx);\n+ self.dirty = true;\n+ idx\n+ }\n+\n+ /// Public test helper: insert an item into the group without a vote (for unit tests).\n+ pub fn ensure_item_pub(&mut self, item: &str) -> usize {\n+ if let Some(canon) = ItemId::parse(item) {\n+ self.ensure_item(&canon)\n+ } else {\n+ // Fallback: treat as raw storage string\n+ let canon = ItemId::opaque(item.to_string());\n+ self.ensure_item(&canon)\n+ }\n+ }\n+\n+ fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n+ if w <= 0.0 {\n+ return;\n+ }\n+ *self.edges.entry((src, dst)).or_insert(0.0) += w;\n+ self.dirty = true;\n+ }\n+\n+ pub fn apply_vote(&mut self, mut vote: VoteData) {\n+ vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n+ vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n+ vote.thread_tag = canonicalize_tag(&vote.thread_tag);\n+ if vote.ratio_left < 0 {\n+ vote.ratio_left = 0;\n+ }\n+ if vote.ratio_right < 0 {\n+ vote.ratio_right = 0;\n+ }\n+ let a_idx = self.ensure_item(&vote.a);\n+ let b_idx = self.ensure_item(&vote.b);\n+\n+ let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };\n+ self.voted_pairs.insert((i, j));\n+\n+ let w_a = vote.ratio_left as f64;\n+ let w_b = vote.ratio_right as f64;\n+\n+ self.add_edge_weight(b_idx, a_idx, w_a);\n+ self.add_edge_weight(a_idx, b_idx, w_b);\n+\n+ self.recent_votes.push_front(vote);\n+ while self.recent_votes.len() > 200 {\n+ self.recent_votes.pop_back();\n+ }\n+ }\n+}\n+\n+/// Compact rank-history entry stored per item in the ledger.\n+/// `caused_by` is resolved lazily at query time from `ingests_by_id`.\n+#[derive(Debug, Clone)]\n+pub struct RankHistoryEntry {\n+ pub ts: i64,\n+ pub scope_rank: usize,\n+ pub scope_rank_delta: i32,\n+ pub scope_total: usize,\n+ pub global_rank: usize,\n+ pub global_rank_delta: i32,\n+ pub global_total: usize,\n+ pub score: f64,\n+ pub thread: String,\n+ pub post_id: String,\n+}\n+\n+/// Invite link materialized from log events [`crate::events::InviteMinted`] /\n+/// [`crate::events::InviteRedeemed`] when those are replayed.\n+///\n+/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);\n+/// they are not appended to the JSONL log, so they disappear on restart. This struct is for\n+/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.\n+#[derive(Debug, Clone)]\n+pub struct ActiveInviteState {\n+ pub room_id: String,\n+ pub capabilities: HashSet,\n+ pub inviter: String,\n+ pub uses_remaining: u32,\n+ pub expires_ts_ms: Option,\n+}\n+\n+#[derive(Clone, Debug)]\n+pub enum RoomTimelineKind {\n+ RoomCreated {\n+ owner: String,\n+ slug: String,\n+ },\n+ RoomDeleted {\n+ deleted_by: String,\n+ },\n+ GrantAdded {\n+ username: String,\n+ granted_by: String,\n+ capabilities: Vec,\n+ },\n+ GrantRevoked {\n+ username: String,\n+ revoked_by: String,\n+ capabilities: Vec,\n+ },\n+}\n+\n+#[derive(Clone, Debug)]\n+pub struct RoomTimelineEntry {\n+ pub ts: i64,\n+ pub kind: RoomTimelineKind,\n+}\n+\n+#[derive(Debug, Clone)]\n+#[derive(Default)]\n+pub struct ForumThreadState {\n+ pub last_activity_ts: i64,\n+ /// Username of the most recent person who bumped this thread.\n+ pub last_actor: String,\n+}\n+\n+\n+#[derive(Debug, Clone, Default)]\n+pub struct ContentState {\n+ pub ranking_group: GroupState,\n+ pub items: HashSet,\n+ pub item_bodies: HashMap,\n+ /// Parent [`ItemId`] -> direct children.\n+ pub item_children: HashMap>,\n+ /// Per-item vote history (most recent first).\n+ pub item_votes: HashMap>,\n+ /// Per-item ingest references (most recent first).\n+ pub item_snippets: HashMap>,\n+ /// Item path -> threads that mention or vote on this item.\n+ pub item_threads: HashMap>,\n+ /// Per-item rank history, oldest first.\n+ pub rank_history: HashMap>,\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct ReducerState {\n+ pub content: HashMap,\n+\n+ /// (provider, provider_id) -> username\n+ pub users_by_provider: HashMap<(String, String), String>,\n+ /// token_id -> (username, salt, token_hash)\n+ pub tokens_by_id: HashMap,\n+ pub agent_bindings: HashMap,\n+\n+\n+ pub ingests_by_id: HashMap,\n+ /// (scope, thread_tag) → ingest ids, newest first.\n+ pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,\n+ /// Private room ids (`shortid/slug`) known from [`RoomCreated`].\n+ pub rooms: HashSet,\n+ /// (scope, thread_tag) → last activity.\n+ pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,\n+ pub actor_last_post_ts: HashMap,\n+\n+ /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.\n+ pub ingests_ordered: Vec,\n+ /// Username → ingest ids in post order (oldest first), for profile pages.\n+ pub posts_by_actor: HashMap>,\n+ /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).\n+ pub redacted_posts: HashSet,\n+ /// Redaction event timestamp (ms) per post id.\n+ pub post_redact_ts: HashMap,\n+ /// room_id → username → capabilities\n+ pub grants: HashMap>>,\n+ /// room_id → chronological room admin lines (for thread UI).\n+ pub room_timeline: HashMap>,\n+ /// Invite token → active invite (absent when fully consumed or never minted).\n+ pub invites: HashMap,\n+}\n+\n+impl ReducerState {\n+ pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {\n+ self.content.get(scope)\n+ }\n+\n+ /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).\n+ pub fn try_thread_post_index_chronological(\n+ &self,\n+ scope: &ScopeId,\n+ thread_tag: &str,\n+ post_id: &str,\n+ ) -> Option {\n+ let tag = canonicalize_tag(thread_tag);\n+ self.ingests_by_scope_thread\n+ .get(&(scope.clone(), tag))\n+ .and_then(|q| q.iter().rev().position(|pid| pid == post_id))\n+ }\n+\n+ pub fn thread_post_index_chronological(\n+ &self,\n+ scope: &ScopeId,\n+ thread_tag: &str,\n+ post_id: &str,\n+ ) -> usize {\n+ self.try_thread_post_index_chronological(scope, thread_tag, post_id)\n+ .expect(\n+ \"post_id must appear in ingests_by_scope_thread for this scope and thread\",\n+ )\n+ }\n+\n+ /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.\n+ pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {\n+ self.posts_by_actor\n+ .get(actor)\n+ .map(|q| q.iter().cloned().collect())\n+ .unwrap_or_default()\n+ }\n+\n+ /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.\n+ pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {\n+ self.posts_by_actor_ids(actor)\n+ .into_iter()\n+ .filter(|id| {\n+ !self.redacted_posts.contains(id)\n+ && self.ingests_by_id.get(id).is_some_and(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ match &scope {\n+ ScopeId::Public => true,\n+ ScopeId::Room(rid) => {\n+ viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))\n+ }\n+ }\n+ })\n+ })\n+ .collect()\n+ }\n+\n+ pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {\n+ self.grants\n+ .get(room_id)\n+ .and_then(|t| t.get(username))\n+ .map(|caps| caps.contains(&cap))\n+ .unwrap_or(false)\n+ }\n+\n+ /// Invite link is present, not expired, and has uses left.\n+ pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {\n+ let inv = self.invites.get(token)?;\n+ if inv.uses_remaining == 0 {\n+ return None;\n+ }\n+ if let Some(exp) = inv.expires_ts_ms {\n+ if now_ms > exp {\n+ return None;\n+ }\n+ }\n+ Some(inv)\n+ }\n+\n+ pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {\n+ self.content.entry(scope).or_default()\n+ }\n+\n+ pub fn public(&self) -> &ContentState {\n+ self.content.get(&ScopeId::Public).expect(\"public scope missing\")\n+ }\n+\n+ /// Register parent→child edges for the full ancestor chain.\n+ /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `\"\"→a`.\n+ /// Stops early when an intermediate is already registered (its ancestors must be too).\n+ /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4\n+ fn add_child_edge(content: &mut ContentState, item: &ItemId) {\n+ let mut child = item.clone();\n+ loop {\n+ let Some(parent) = child.parent() else { break };\n+ let is_new = content\n+ .item_children\n+ .entry(parent.clone())\n+ .or_default()\n+ .insert(child);\n+ if !is_new { break; }\n+ child = parent;\n+ }\n+ }\n+\n+ /// Resolve an item path as a first-class [`ItemId`].\n+ fn normalize_item(item: &str) -> Option {\n+ ItemId::parse(item)\n+ }\n+\n+ /// 1-indexed rank of `item` within its connected component in the parent scope.\n+ /// 0 if the item has no votes connecting it to siblings (unranked).\n+ fn scope_rank_of(\n+ group: &GroupState,\n+ item: &ItemId,\n+ item_children: &HashMap>,\n+ ) -> usize {\n+ let scope = match item.parent() {\n+ Some(p) => p,\n+ None => return 0,\n+ };\n+ let children = match item_children.get(&scope) {\n+ None => return 0,\n+ Some(c) => c,\n+ };\n+ let &item_global_idx = match group.item_to_idx.get(item) {\n+ None => return 0,\n+ Some(i) => i,\n+ };\n+ // Map scope children to compact local indices.\n+ let sibling_idxs: Vec = children.iter()\n+ .filter_map(|c| group.item_to_idx.get(c).copied())\n+ .collect();\n+ let global_to_local: HashMap = sibling_idxs.iter()\n+ .enumerate().map(|(l, &g)| (g, l)).collect();\n+ let item_local = match global_to_local.get(&item_global_idx) {\n+ None => return 0,\n+ Some(&l) => l,\n+ };\n+ // Connected components within scope.\n+ let (comps, _) = crate::ranking::connected_components_from_voted_pairs(\n+ sibling_idxs.len(),\n+ group.voted_pairs.iter().filter_map(|(a, b)| {\n+ Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))\n+ }),\n+ );\n+ // Find the component containing this item.\n+ let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {\n+ None => return 0,\n+ Some(c) => c,\n+ };\n+ let comp_global: Vec = comp_local.iter()\n+ .filter_map(|&l| sibling_idxs.get(l).copied())\n+ .collect();\n+ let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);\n+ ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)\n+ }\n+\n+ /// 1-indexed position of `item` in the component-aware global flat list.\n+ /// Components sorted largest-first; items ranked within each component.\n+ /// 0 if the item is not in the ranking group.\n+ fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {\n+ if !group.item_to_idx.contains_key(item) {\n+ return 0;\n+ }\n+ let n = group.idx_to_item.len();\n+ let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(\n+ n, group.voted_pairs.iter().copied(),\n+ );\n+ comps.sort_by_key(|b| std::cmp::Reverse(b.len()));\n+ let mut pos = 1usize;\n+ for comp in &comps {\n+ let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);\n+ for r in &ranked {\n+ if &r.item == item {\n+ return pos;\n+ }\n+ pos += 1;\n+ }\n+ }\n+ 0\n+ }\n+\n+ /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).\n+ fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {\n+ let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;\n+ let canonical_thread = canonicalize_tag(&ing.thread_tag);\n+\n+ let voted_items: Vec = doc\n+ .statements\n+ .iter()\n+ .filter_map(|s| {\n+ if let dsl::Stmt::Vote { item1, item2, .. } = s {\n+ Some([item1, item2])\n+ } else {\n+ None\n+ }\n+ })\n+ .flat_map(|pair| pair.into_iter())\n+ .filter_map(|raw| Self::normalize_item(raw))\n+ .collect::>()\n+ .into_iter()\n+ .collect();\n+\n+ let principal = ing.principal.clone();\n+ let delegate = ing.delegate.clone();\n+\n+ let before: HashMap = if !voted_items.is_empty() {\n+ crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n+ voted_items\n+ .iter()\n+ .map(|it| {\n+ (\n+ it.clone(),\n+ (\n+ Self::scope_rank_of(&content.ranking_group, it, &content.item_children),\n+ Self::global_rank_of(&content.ranking_group, it),\n+ ),\n+ )\n+ })\n+ .collect()\n+ } else {\n+ HashMap::new()\n+ };\n+\n+ let mut ingest_items: HashSet = HashSet::new();\n+\n+ for stmt in doc.statements {\n+ match stmt {\n+ dsl::Stmt::Item { title, body } => {\n+ let Some(item) = Self::normalize_item(&title) else {\n+ continue;\n+ };\n+ nav!(content.items, set_elem(item.clone()));\n+ ingest_items.insert(item.clone());\n+ Self::add_child_edge(content, &item);\n+\n+ if let Some(body_text) = body {\n+ if !body_text.trim().is_empty() {\n+ nav!(content.item_bodies, keypath(item.clone()), setval(body_text));\n+ }\n+ }\n+ }\n+ dsl::Stmt::Vote {\n+ item1,\n+ item2,\n+ ratio_left,\n+ ratio_right,\n+ explanation,\n+ } => {\n+ let Some(item_a) = Self::normalize_item(&item1) else {\n+ continue;\n+ };\n+ let Some(item_b) = Self::normalize_item(&item2) else {\n+ continue;\n+ };\n+\n+ let vote = VoteData {\n+ ts: ing.ts,\n+ a: item_a.clone(),\n+ b: item_b.clone(),\n+ ratio_left,\n+ ratio_right,\n+ body: explanation,\n+ principal: principal.clone(),\n+ delegate: delegate.clone(),\n+ thread_tag: canonical_thread.clone(),\n+ };\n+\n+ ingest_items.insert(item_a.clone());\n+ ingest_items.insert(item_b.clone());\n+\n+ nav!(content.items, set_elem(item_a.clone()));\n+ nav!(content.items, set_elem(item_b.clone()));\n+ Self::add_child_edge(content, &item_a);\n+ Self::add_child_edge(content, &item_b);\n+\n+ content.ranking_group.apply_vote(vote.clone());\n+\n+ for it in [&item_a, &item_b] {\n+ nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));\n+ }\n+ }\n+ dsl::Stmt::Prose { .. } => {}\n+ }\n+ }\n+\n+ for item in ingest_items.iter() {\n+ nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));\n+ }\n+\n+ for item in ingest_items.iter() {\n+ nav!(\n+ content.item_threads,\n+ keypath(item.clone()),\n+ set_elem(canonical_thread.clone())\n+ );\n+ }\n+\n+ if !voted_items.is_empty() {\n+ crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n+ let thread = canonical_thread.clone();\n+ for item in &voted_items {\n+ let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);\n+ let after_global = Self::global_rank_of(&content.ranking_group, item);\n+ let score = content\n+ .ranking_group\n+ .item_to_idx\n+ .get(item)\n+ .and_then(|&i| content.ranking_group.cached_scores.get(i))\n+ .copied()\n+ .unwrap_or(0.0);\n+ let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));\n+ let prev = content.rank_history.get(item).and_then(|v| v.last());\n+ let scope_delta = if prev.is_none() {\n+ 0\n+ } else {\n+ after_scope as i32 - before_scope as i32\n+ };\n+ let global_delta = if prev.is_none() {\n+ 0\n+ } else {\n+ after_global as i32 - before_global as i32\n+ };\n+ let scope_total = item\n+ .parent()\n+ .and_then(|p| content.item_children.get(&p))\n+ .map(|s| s.len())\n+ .unwrap_or(0);\n+ let global_total = content.ranking_group.idx_to_item.len();\n+ content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {\n+ ts: ing.ts,\n+ scope_rank: after_scope,\n+ scope_rank_delta: scope_delta,\n+ scope_total,\n+ global_rank: after_global,\n+ global_rank_delta: global_delta,\n+ global_total,\n+ score,\n+ thread: thread.clone(),\n+ post_id: ing.id.clone(),\n+ });\n+ }\n+ }\n+\n+ Ok(())\n+ }\n+\n+ fn rebuild_scope_content(&mut self, scope: ScopeId) {\n+ let mut cs = ContentState::default();\n+ for id in &self.ingests_ordered {\n+ if self.redacted_posts.contains(id) {\n+ continue;\n+ }\n+ let Some(ing) = self.ingests_by_id.get(id) else {\n+ continue;\n+ };\n+ if scope_from_room_wire(&ing.room_id) != scope {\n+ continue;\n+ }\n+ let _ = Self::apply_ingest_to_content(&mut cs, ing);\n+ }\n+ self.content.insert(scope, cs);\n+ // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so\n+ // per-post URLs and chronological indices remain stable; only projected garden state resets.\n+ }\n+\n+ /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).\n+ fn purge_private_room(&mut self, room_id: &str) {\n+ let scope = ScopeId::Room(room_id.to_string());\n+ self.rooms.remove(room_id);\n+ self.grants.remove(room_id);\n+ self.room_timeline.remove(room_id);\n+ self.invites.retain(|_, inv| inv.room_id != room_id);\n+ self.content.remove(&scope);\n+ self.forum_threads.retain(|(s, _), _| s != &scope);\n+ self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);\n+\n+ let mut to_drop: Vec = self\n+ .ingests_by_id\n+ .iter()\n+ .filter(|(_, ing)| ing.room_id.trim() == room_id)\n+ .map(|(id, _)| id.clone())\n+ .collect();\n+ to_drop.sort();\n+ to_drop.dedup();\n+ for id in to_drop {\n+ if let Some(ing) = self.ingests_by_id.remove(&id) {\n+ self.posts_by_actor\n+ .entry(ing.principal)\n+ .and_modify(|q| {\n+ q.retain(|x| x != &id);\n+ });\n+ }\n+ self.ingests_ordered.retain(|x| x != &id);\n+ self.redacted_posts.remove(&id);\n+ self.post_redact_ts.remove(&id);\n+ }\n+ }\n+\n+ pub fn apply_event(&mut self, event: Event) {\n+ match event {\n+ Event::UserRegistered(ur) => {\n+ self.users_by_provider.insert(\n+ (ur.provider.to_lowercase(), ur.provider_id.clone()),\n+ ur.username,\n+ );\n+ }\n+ Event::TokenIssued(ti) => {\n+ self.tokens_by_id.insert(\n+ ti.token_id.clone(),\n+ (ti.username, ti.salt.clone(), ti.token_hash.clone()),\n+ );\n+ }\n+ Event::AgentBound(ab) => {\n+ if ab.agent.is_empty() {\n+ return;\n+ }\n+ self.agent_bindings.insert(ab.agent, ab.username);\n+ }\n+ Event::RoomCreated(rc) => {\n+ self.rooms.insert(rc.room_id.clone());\n+ self.room_timeline\n+ .entry(rc.room_id.clone())\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: rc.ts,\n+ kind: RoomTimelineKind::RoomCreated {\n+ owner: rc.owner.clone(),\n+ slug: rc.slug.clone(),\n+ },\n+ });\n+ }\n+ Event::RoomDeleted(rd) => {\n+ let room_id = rd.room_id.clone();\n+ if self.rooms.contains(&room_id) {\n+ self.room_timeline\n+ .entry(room_id.clone())\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: rd.ts,\n+ kind: RoomTimelineKind::RoomDeleted {\n+ deleted_by: rd.deleted_by.clone(),\n+ },\n+ });\n+ }\n+ self.purge_private_room(&room_id);\n+ }\n+ Event::Ingest(mut ing) => {\n+ ing.thread_tag = canonicalize_tag(&ing.thread_tag);\n+ let room_key = ing.room_id.trim().to_string();\n+ let scope = scope_from_room_wire(&room_key);\n+ let canonical_thread = ing.thread_tag.clone();\n+ let scope_thread_key = (scope.clone(), canonical_thread.clone());\n+\n+ {\n+ let content = self.content_for_scope_mut(scope.clone());\n+ if Self::apply_ingest_to_content(content, &ing).is_err() {\n+ eprintln!(\n+ \"WARNING: Skipping malformed ingest event {}: parse failed\",\n+ ing.id\n+ );\n+ return;\n+ }\n+ }\n+\n+ self.ingests_by_id.insert(ing.id.clone(), ing.clone());\n+\n+ let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();\n+ let prev_ts = ft.last_activity_ts;\n+ if ing.ts > prev_ts {\n+ ft.last_activity_ts = ing.ts;\n+ ft.last_actor = ing.principal.clone();\n+ }\n+\n+ nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));\n+\n+ nav!(self.ingests_ordered, push_back(ing.id.clone()));\n+\n+ nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));\n+\n+ nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));\n+ }\n+ Event::PostRedacted(pr) => {\n+ self.redacted_posts.insert(pr.post_id.clone());\n+ self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);\n+ let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {\n+ return;\n+ };\n+ let scope = scope_from_room_wire(ing.room_id.trim());\n+ // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.\n+ self.rebuild_scope_content(scope);\n+ }\n+ Event::GrantAdded(ga) => {\n+ let room_id = ga.room_id.clone();\n+ let caps = self.grants\n+ .entry(ga.room_id)\n+ .or_default()\n+ .entry(ga.username.clone())\n+ .or_default();\n+ for cap in ga.capabilities.iter().copied() {\n+ caps.insert(cap);\n+ }\n+ self.room_timeline\n+ .entry(room_id)\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: ga.ts,\n+ kind: RoomTimelineKind::GrantAdded {\n+ username: ga.username.clone(),\n+ granted_by: ga.granted_by.clone(),\n+ capabilities: ga.capabilities.clone(),\n+ },\n+ });\n+ }\n+ Event::GrantRevoked(gr) => {\n+ let room_id = gr.room_id.clone();\n+ if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {\n+ let username = gr.username.clone();\n+ if let Some(caps) = room_grants.get_mut(&username) {\n+ for cap in &gr.capabilities {\n+ caps.remove(cap);\n+ }\n+ if caps.is_empty() {\n+ room_grants.remove(&username);\n+ }\n+ }\n+ if room_grants.is_empty() {\n+ self.grants.remove(&gr.room_id);\n+ }\n+ }\n+ self.room_timeline\n+ .entry(room_id)\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: gr.ts,\n+ kind: RoomTimelineKind::GrantRevoked {\n+ username: gr.username.clone(),\n+ revoked_by: gr.revoked_by.clone(),\n+ capabilities: gr.capabilities.clone(),\n+ },\n+ });\n+ }\n+ Event::InviteMinted(im) => {\n+ self.invites.insert(\n+ im.token.clone(),\n+ ActiveInviteState {\n+ room_id: im.room_id.clone(),\n+ capabilities: im.capabilities.iter().copied().collect(),\n+ inviter: im.inviter.clone(),\n+ uses_remaining: im.max_uses,\n+ expires_ts_ms: im.expires_ts_ms,\n+ },\n+ );\n+ }\n+ Event::InviteRedeemed(ir) => {\n+ if let Some(inv) = self.invites.get_mut(&ir.token) {\n+ inv.uses_remaining = inv.uses_remaining.saturating_sub(1);\n+ if inv.uses_remaining == 0 {\n+ self.invites.remove(&ir.token);\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+impl Default for ReducerState {\n+ fn default() -> Self {\n+ let mut content = HashMap::new();\n+ content.insert(ScopeId::Public, ContentState::default());\n+ Self {\n+ content,\n+ users_by_provider: HashMap::new(),\n+ tokens_by_id: HashMap::new(),\n+ agent_bindings: HashMap::new(),\n+ ingests_by_id: HashMap::new(),\n+ ingests_by_scope_thread: HashMap::new(),\n+ rooms: HashSet::new(),\n+ forum_threads: HashMap::new(),\n+ actor_last_post_ts: HashMap::new(),\n+ ingests_ordered: Vec::new(),\n+ posts_by_actor: HashMap::new(),\n+ redacted_posts: HashSet::new(),\n+ post_redact_ts: HashMap::new(),\n+ grants: HashMap::new(),\n+ room_timeline: HashMap::new(),\n+ invites: HashMap::new(),\n+ }\n+ }\n+}\n+\ndiff --git a/seed.tdsl b/seed.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..499a615041e3be5987dcb306ede84f4ce936e0be\n--- /dev/null\n+++ b/seed.tdsl\n@@ -0,0 +1,21 @@\n+reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social?\n+jsonl based. Same stack as slug\n+fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval\n+Viewcounters for every site\n+Login with Reddit oauth?\n+Streaks? Streak gates? Progressive revelation?\n+One pair of the day\n+Use attributes again, for assholery. To distinguish from reddits single dimension\n+r/sorter_south_park_characters \n+Is a one shot category that only has the characters.\n+subs starting with sorter have special priority in sorter index.\n+\n+layer above reddit?\n+\n+\n+use websocket based bar i had built in afterlife... \n+\n+\n+claude {{\n+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.\n+}}\ndiff --git a/ui_action.rs b/ui_action.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5e4131dd346a6f80bfe091a9b0cf7902721bc47f\n--- /dev/null\n+++ b/ui_action.rs\n@@ -0,0 +1,270 @@\n+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).\n+//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).\n+\n+use crate::form_template::fill_template_from_form;\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+use std::collections::HashMap;\n+use thiserror::Error;\n+\n+/// Form field name for the compact JSON template (possibly with `{\"$form\":\"…\"}` holes).\n+pub const UI_RPC_FIELD: &str = \"__rpc__\";\n+\n+fn default_ui_form_action() -> String {\n+ \"/ui\".to_string()\n+}\n+\n+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[serde(tag = \"action\", rename_all = \"snake_case\")]\n+pub enum HtmlUiAction {\n+ /// Forum ingest via `POST /ui`.\n+ PostIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ #[serde(default)]\n+ error_target: Option,\n+ #[serde(default)]\n+ form_id: Option,\n+ },\n+ /// DSL check / validation via `POST /ui`.\n+ CheckIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ #[serde(default)]\n+ error_target: Option,\n+ #[serde(default)]\n+ form_id: Option,\n+ },\n+ /// Post a pairwise vote from `/vote` (browser compose).\n+ VoteComparePost {\n+ room: String,\n+ thread_tag: String,\n+ left_item: String,\n+ right_item: String,\n+ /// From form fields (string); parsed server-side.\n+ ratio_left: String,\n+ ratio_right: String,\n+ explanation: String,\n+ /// Same-origin path after successful post (JS redirect).\n+ next: String,\n+ /// Pool parent item path, if the vote was initiated from a pool URL.\n+ #[serde(default)]\n+ pool: Option,\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n+ },\n+ /// 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`.\n+ SetGardenPin {\n+ #[serde(default)]\n+ clear: bool,\n+ #[serde(default)]\n+ room_wire: String,\n+ #[serde(default)]\n+ item_storage: Option,\n+ next: String,\n+ /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n+ },\n+ /// Resolve children or siblings for an external garden item.\n+ ResolveExternal {\n+ room_wire: String,\n+ item_storage: String,\n+ mode: String,\n+ next: String,\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n+ },\n+ /// Author redacts own post via `POST /ui`.\n+ RedactPost { post_id: String },\n+ /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n+ SetRoomMembersExpanded {\n+ room_wire: String,\n+ #[serde(default)]\n+ expanded: bool,\n+ },\n+ /// Delete the private room (Manage only); redirects to `/` on success.\n+ DeleteRoom { room: String },\n+ /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n+ SetNewThreadComposeExpanded {\n+ room_wire: String,\n+ #[serde(default)]\n+ expanded: bool,\n+ },\n+ /// Replace a truncated post card with the full body (same thread index).\n+ ExpandPostFull {\n+ room: String,\n+ thread_tag: String,\n+ post_index: usize,\n+ },\n+ /// Expand a redacted/tombstone post to show stripped body (author view).\n+ ExpandRedactedPost {\n+ room: String,\n+ thread_tag: String,\n+ post_index: usize,\n+ },\n+ /// Collapse an expanded redacted post back to the tombstone card.\n+ CollapseRedactedPost {\n+ room: String,\n+ thread_tag: String,\n+ post_index: usize,\n+ },\n+ /// Copy full thread text (CLI `forum show` format) to the clipboard.\n+ CopyThread {\n+ room: String,\n+ thread_tag: String,\n+ copy_btn_id: String,\n+ },\n+}\n+\n+#[derive(Debug, Error)]\n+pub enum HtmlUiParseError {\n+ #[error(\"missing __rpc__ field\")]\n+ MissingRpc,\n+ #[error(\"invalid template json: {0}\")]\n+ Template(serde_json::Error),\n+ #[error(\"invalid ui action: {0}\")]\n+ Action(serde_json::Error),\n+}\n+\n+/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.\n+pub fn parse_html_ui_from_form(\n+ form: &HashMap,\n+) -> Result {\n+ let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;\n+ let mut hole_map = form.clone();\n+ hole_map.remove(UI_RPC_FIELD);\n+ let v: Value =\n+ fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n+ serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn round_trip_post_ingest_with_holes() {\n+ let template = serde_json::json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"e\",\n+ \"form_id\": \"f\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ form.insert(\"thread_tag\".into(), \"x\".into());\n+ form.insert(\"text\".into(), \"body\".into());\n+\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::PostIngest {\n+ room: \"public\".into(),\n+ thread_tag: \"x\".into(),\n+ text: \"body\".into(),\n+ error_target: Some(\"e\".into()),\n+ form_id: Some(\"f\".into()),\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn expand_post_full_round_trip() {\n+ let template = serde_json::json!({\n+ \"action\": \"expand_post_full\",\n+ \"room\": \"public\",\n+ \"thread_tag\": \"demo\",\n+ \"post_index\": 3,\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::ExpandPostFull {\n+ room: \"public\".into(),\n+ thread_tag: \"demo\".into(),\n+ post_index: 3,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn set_room_members_expanded_defaults_false() {\n+ let template = serde_json::json!({\n+ \"action\": \"set_room_members_expanded\",\n+ \"room_wire\": \"ab/cd\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::SetRoomMembersExpanded {\n+ room_wire: \"ab/cd\".into(),\n+ expanded: false,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn copy_thread_round_trip() {\n+ let template = serde_json::json!({\n+ \"action\": \"copy_thread\",\n+ \"room\": \"public\",\n+ \"thread_tag\": \"demo\",\n+ \"copy_btn_id\": \"thread-copy-top\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::CopyThread {\n+ room: \"public\".into(),\n+ thread_tag: \"demo\".into(),\n+ copy_btn_id: \"thread-copy-top\".into(),\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn set_new_thread_compose_expanded_true() {\n+ let template = serde_json::json!({\n+ \"action\": \"set_new_thread_compose_expanded\",\n+ \"room_wire\": \"ab/cd\",\n+ \"expanded\": true,\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::SetNewThreadComposeExpanded {\n+ room_wire: \"ab/cd\".into(),\n+ expanded: true,\n+ }\n+ );\n+ }\n+}\ndiff --git a/vote.rs b/vote.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d0ec78cd675eae284d056fb3b8eaf5cc853d6263\n--- /dev/null\n+++ b/vote.rs\n@@ -0,0 +1,580 @@\n+use axum::{\n+ extract::{Path, Query, State},\n+ http::{HeaderMap, StatusCode, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::html;\n+use serde::Deserialize;\n+use serde_json::json;\n+use std::collections::{HashMap, HashSet};\n+\n+use crate::{\n+ api::optional_principal,\n+ canonical_path::canonicalize_tag,\n+ form_template::template_json_compact,\n+ html::{\n+ forum::ThreadNav,\n+ layout_full_bleed_chromeless,\n+ ratio_pct, render_item_body_in_scope,\n+ theme_from_jar, theme_next_from_uri,\n+ user_can_post_room,\n+ ui_action::UI_RPC_FIELD,\n+ JsBuilder,\n+ },\n+ middleware::canonical_view_url,\n+ path_types::ItemId,\n+ reducer::{ContentState, ScopeId},\n+ scope_rank::suggest_next_pair_in_pool,\n+ state::AppState,\n+};\n+\n+use super::{\n+ access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},\n+ item::{item_display_path, login_href_with_next},\n+};\n+\n+fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {\n+ let cands: HashSet = content\n+ .item_threads\n+ .get(a)\n+ .into_iter()\n+ .chain(content.item_threads.get(b))\n+ .flat_map(|s| s.iter().cloned())\n+ .collect();\n+ if cands.is_empty() {\n+ return \"vote\".to_string();\n+ }\n+ let mut v: Vec = cands.into_iter().collect();\n+ v.sort();\n+ canonicalize_tag(&v[0])\n+}\n+\n+/// Canonical unordered pair: lexicographic by storage string (stable edge identity).\n+pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n+ let ac = a.clone().normalized_storage();\n+ let bc = b.clone().normalized_storage();\n+ if ac.as_str() <= bc.as_str() {\n+ (ac, bc)\n+ } else {\n+ (bc, ac)\n+ }\n+}\n+\n+/// All votes whose endpoints are exactly this unordered pair (unsorted).\n+pub(super) fn edge_vote_entries_for_pair(\n+ content: &ContentState,\n+ a: &ItemId,\n+ b: &ItemId,\n+) -> Vec {\n+ let (lo, hi) = canonical_edge_items(a, b);\n+ let lo_s = lo.as_str();\n+ let hi_s = hi.as_str();\n+ content\n+ .item_votes\n+ .get(&lo)\n+ .into_iter()\n+ .flat_map(|q| q.iter())\n+ .filter(|v| {\n+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)\n+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)\n+ })\n+ .cloned()\n+ .collect()\n+}\n+\n+pub(super) fn ratios_for_compare_page(\n+ v: &crate::reducer::VoteData,\n+ page_left: &ItemId,\n+ page_right: &ItemId,\n+) -> (i32, i32) {\n+ let pl = page_left.as_str();\n+ let pr = page_right.as_str();\n+ match (v.a.as_str(), v.b.as_str()) {\n+ (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right),\n+ (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left),\n+ _ => (v.ratio_left, v.ratio_right),\n+ }\n+}\n+\n+fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {\n+ let l = ratio_left.max(0) as f64;\n+ let r = ratio_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ 0.5\n+ } else {\n+ l / sum\n+ }\n+}\n+\n+/// Stronger preference for **`page_left` first**; ties **newer first**.\n+pub(super) fn sort_votes_for_compare_display(\n+ mut votes: Vec,\n+ page_left: &ItemId,\n+ page_right: &ItemId,\n+) -> Vec {\n+ votes.sort_by(|va, vb| {\n+ let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right);\n+ let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right);\n+ let sa = left_share_normalized(ratio_left_a, ratio_right_a);\n+ let sb = left_share_normalized(ratio_left_b, ratio_right_b);\n+ match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {\n+ std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts),\n+ o => o,\n+ }\n+ });\n+ votes\n+}\n+\n+/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).\n+pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {\n+ let (lo, hi) = canonical_edge_items(a, b);\n+ let lo_s = lo.as_str();\n+ let hi_s = hi.as_str();\n+ content\n+ .item_votes\n+ .get(&lo)\n+ .into_iter()\n+ .flat_map(|q| q.iter())\n+ .filter(|v| {\n+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)\n+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)\n+ })\n+ .count()\n+}\n+\n+fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {\n+ let set: HashSet = content\n+ .item_threads\n+ .get(a)\n+ .into_iter()\n+ .chain(content.item_threads.get(b))\n+ .flat_map(|s| s.iter().cloned())\n+ .collect();\n+ let mut v: Vec = set.into_iter().collect();\n+ v.sort();\n+ v.into_iter().map(|t| canonicalize_tag(&t)).collect()\n+}\n+\n+fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {\n+ let votes = edge_vote_entries_for_pair(content, left, right);\n+ let votes = sort_votes_for_compare_display(votes, left, right);\n+ let legend_left = item_display_path(left.as_str());\n+ let legend_right = item_display_path(right.as_str());\n+ html! {\n+ @if votes.is_empty() {\n+ p class=\"muted vote-edge-empty\" { \"no votes on this pair in this scope yet\" }\n+ } @else {\n+ h3 class=\"vote-edge-history-title\" {\n+ \"votes on this edge\"\n+ span class=\"vote-edge-history-axis muted\" { \" · \" (legend_left) \" : \" (legend_right) }\n+ }\n+ ul class=\"vote-edge-history\" {\n+ @for v in &votes {\n+ @let (r_left, r_right) = ratios_for_compare_page(v, left, right);\n+ @let pct = ratio_pct(r_left, r_right);\n+ @let row_tip = format!(\n+ \"{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}\",\n+ r_left,\n+ r_right,\n+ legend_left,\n+ legend_right,\n+ v.thread_tag,\n+ v.principal,\n+ );\n+ li class=\"vote-edge-history-row\" title=(row_tip) {\n+ div class=\"vote-edge-meta\" {\n+ span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n+ span class=\"muted\" { \" · #\" (v.thread_tag) \" · @\" (v.principal) }\n+ }\n+ div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n+ div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n+ div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ }\n+ @if !v.body.trim().is_empty() {\n+ div class=\"vote-edge-reason muted\" { (v.body.trim()) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+/// After a successful vote post: refresh edge history (no in-page preview card).\n+#[allow(clippy::too_many_arguments)]\n+pub(crate) async fn vote_compare_post_success_js(\n+ state: &AppState,\n+ nav: &ThreadNav,\n+ _room_wire: &str,\n+ _thread_tag: &str,\n+ left: &ItemId,\n+ right: &ItemId,\n+ pool: Option<&ItemId>,\n+ _post_id: &str,\n+ _post_idx: Option,\n+) -> String {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let edge_history = vote_edge_history_markup(content, left, right);\n+ let next_pair = suggest_next_vote_pair(content, left, right, pool);\n+ let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);\n+ drop(reduced);\n+ JsBuilder::new()\n+ .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .morph_selector(\".vote-compare-nav\", nav_markup)\n+ .build()\n+}\n+\n+pub(super) fn vote_compare_href(\n+ nav: &ThreadNav,\n+ left: &ItemId,\n+ right: &ItemId,\n+ thread_override: Option<&str>,\n+ pool: Option<&ItemId>,\n+) -> String {\n+ let left_dp = left.display_path();\n+ let right_dp = right.display_path();\n+ let left_q = urlencoding::encode(&left_dp);\n+ let right_q = urlencoding::encode(&right_dp);\n+ let mut base = format!(\n+ \"{}/vote?left={}&right={}\",\n+ nav.room_path_prefix_for_vote_compare(),\n+ left_q,\n+ right_q\n+ );\n+ if let Some(t) = thread_override.filter(|s| !s.is_empty()) {\n+ base = format!(\"{}&thread={}\", base, urlencoding::encode(t));\n+ }\n+ if let Some(p) = pool {\n+ let pool_dp = p.display_path();\n+ base = format!(\"{}&pool={}\", base, urlencoding::encode(&pool_dp));\n+ }\n+ base\n+}\n+\n+pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {\n+ let display = ItemId::parse(pool_item_str)\n+ .map(|i| i.display_path())\n+ .unwrap_or_else(|| pool_item_str.to_string());\n+ format!(\n+ \"{}/vote?pool={}\",\n+ nav.room_path_prefix_for_vote_compare(),\n+ urlencoding::encode(&display)\n+ )\n+}\n+\n+fn vote_compare_nav_markup(\n+ nav: &ThreadNav,\n+ next_pair: Option<&(ItemId, ItemId)>,\n+ pool: Option<&ItemId>,\n+) -> maud::Markup {\n+ let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));\n+ html! {\n+ div class=\"vote-compare-nav\" {\n+ @if let Some(href) = &next_pair_href {\n+ a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n+ } @else {\n+ span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n+ }\n+ }\n+ }\n+}\n+\n+pub(super) fn suggest_next_vote_pair(\n+ content: &ContentState,\n+ current_left: &ItemId,\n+ current_right: &ItemId,\n+ pool_parent: Option<&ItemId>,\n+) -> Option<(ItemId, ItemId)> {\n+ let pool: Vec = if let Some(parent) = pool_parent {\n+ content\n+ .item_children\n+ .get(parent)\n+ .map(|s| s.iter().cloned().collect())\n+ .unwrap_or_default()\n+ } else if current_left.parent().as_ref().map(|p| p.as_str())\n+ == current_right.parent().as_ref().map(|p| p.as_str())\n+ {\n+ current_left\n+ .parent()\n+ .and_then(|parent| {\n+ content\n+ .item_children\n+ .get(&parent.normalized_storage())\n+ .cloned()\n+ })\n+ .map(|children| children.into_iter().collect())\n+ .unwrap_or_default()\n+ } else {\n+ Vec::new()\n+ };\n+ if pool.len() < 2 {\n+ return None;\n+ }\n+ suggest_next_pair_in_pool(\n+ &content.ranking_group,\n+ &pool,\n+ Some((current_left, current_right)),\n+ )\n+}\n+\n+pub(super) fn vote_compare_item_card(\n+ nav: &ThreadNav,\n+ item: &ItemId,\n+ body: Option<&String>,\n+ side_class: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> maud::Markup {\n+ html! {\n+ div class=(format!(\"vote-compare-side {side_class}\")) {\n+ a class=(format!(\"vote-compare-item {side_class}\")) href=(nav.garden_item_href(item)) {\n+ code { (item_display_path(item.as_str())) }\n+ }\n+ @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n+ div class=\"vote-compare-item-body\" {\n+ (render_item_body_in_scope(\n+ body,\n+ nav.garden_root_url(),\n+ item_bodies,\n+ ))\n+ }\n+ } @else {\n+ p class=\"muted vote-compare-item-body-empty\" { \"no body yet\" }\n+ }\n+ }\n+ }\n+}\n+#[derive(Debug, Deserialize)]\n+pub struct VoteCompareQuery {\n+ #[serde(default)]\n+ pub left: Option,\n+ #[serde(default)]\n+ pub right: Option,\n+ #[serde(default)]\n+ pub thread: Option,\n+ #[serde(default)]\n+ pub pool: Option,\n+}\n+\n+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.\n+pub async fn vote_compare_page(\n+ State(state): State,\n+ Query(q): Query,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let nav = ThreadNav::public();\n+ vote_compare_inner(state, q, nav, headers, jar, uri).await\n+}\n+\n+pub async fn room_vote_compare_page(\n+ State(state): State,\n+ Path(room_key): Path,\n+ Query(q): Query,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ if !room_scope_has_garden_content(&reduced, &nav) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ drop(reduced);\n+ vote_compare_inner(state, q, nav, headers, jar, uri).await\n+}\n+\n+async fn vote_compare_inner(\n+ state: AppState,\n+ q: VoteCompareQuery,\n+ nav: ThreadNav,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> axum::response::Response {\n+ let pool_id: Option = match q.pool.as_deref() {\n+ Some(p) => match ItemId::parse(p.trim()) {\n+ Some(i) => Some(i.normalized_storage()),\n+ None => return (StatusCode::BAD_REQUEST, \"bad pool item\").into_response(),\n+ },\n+ None => None,\n+ };\n+\n+ let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {\n+ (Some(l), Some(r)) => {\n+ let left = match ItemId::parse(l.trim()) {\n+ Some(i) => i.normalized_storage(),\n+ None => return (StatusCode::NOT_FOUND, \"bad left item\").into_response(),\n+ };\n+ let right = match ItemId::parse(r.trim()) {\n+ Some(i) => i.normalized_storage(),\n+ None => return (StatusCode::NOT_FOUND, \"bad right item\").into_response(),\n+ };\n+ if left == right {\n+ return (StatusCode::BAD_REQUEST, \"items must differ\").into_response();\n+ }\n+ (left, right)\n+ }\n+ (None, None) => {\n+ let Some(pool) = pool_id.as_ref() else {\n+ return (StatusCode::BAD_REQUEST, \"provide left+right or pool\").into_response();\n+ };\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let children: Vec = content\n+ .item_children\n+ .get(pool)\n+ .map(|s| s.iter().cloned().collect())\n+ .unwrap_or_default();\n+ if children.len() < 2 {\n+ drop(reduced);\n+ return (StatusCode::BAD_REQUEST, \"pool has fewer than 2 children to compare\").into_response();\n+ }\n+ let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);\n+ drop(reduced);\n+ match pair {\n+ Some(p) => p,\n+ None => return (StatusCode::BAD_REQUEST, \"no pairs available in pool\").into_response(),\n+ }\n+ }\n+ _ => return (StatusCode::BAD_REQUEST, \"provide both left and right, or just pool\").into_response(),\n+ };\n+\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let viewer = optional_principal(&headers, &jar, &reduced);\n+ let can_post = match &nav.scope() {\n+ ScopeId::Public => viewer.is_some(),\n+ ScopeId::Room(rid) => viewer\n+ .as_ref()\n+ .map(|u| user_can_post_room(&reduced, rid, u))\n+ .unwrap_or(false),\n+ };\n+ let auto_thread = q\n+ .thread\n+ .as_ref()\n+ .map(|t| canonicalize_tag(t))\n+ .filter(|t| !t.is_empty())\n+ .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));\n+ let thread_tags = vote_thread_tags_for_pair(content, &left, &right);\n+ let edge_history = vote_edge_history_markup(content, &left, &right);\n+ let left_body = content.item_bodies.get(&left).cloned();\n+ let right_body = content.item_bodies.get(&right).cloned();\n+ let item_bodies_for_cards = content.item_bodies.clone();\n+ let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());\n+ drop(reduced);\n+\n+ let title = format!(\n+ \"vote — {} vs {}\",\n+ item_display_path(left.as_str()),\n+ item_display_path(right.as_str())\n+ );\n+ let next_path = uri\n+ .path_and_query()\n+ .map(|pq| pq.as_str().to_string())\n+ .unwrap_or_else(|| \"/vote\".into());\n+\n+ let rpc_json = template_json_compact(&json!({\n+ \"action\": \"vote_compare_post\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"left_item\": left.as_str(),\n+ \"right_item\": right.as_str(),\n+ \"ratio_left\": {\"$form\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form\": \"ratio_right\"},\n+ \"explanation\": {\"$form\": \"explanation\"},\n+ \"next\": next_path,\n+ \"pool\": pool_id.as_ref().map(|p| p.as_str()),\n+ \"form_action\": \"/ui\",\n+ }))\n+ .expect(\"vote compare rpc json\");\n+\n+ let body = html! {\n+ section class=\"vote-compare-shell\" {\n+ h2 { \"compare\" }\n+ div class=\"vote-compare-pair\" {\n+ (vote_compare_item_card(\n+ &nav,\n+ &left,\n+ left_body.as_ref(),\n+ \"vote-compare-left\",\n+ Some(&item_bodies_for_cards),\n+ ))\n+ span class=\"vote-compare-vs\" { \"vs\" }\n+ (vote_compare_item_card(\n+ &nav,\n+ &right,\n+ right_body.as_ref(),\n+ \"vote-compare-right\",\n+ Some(&item_bodies_for_cards),\n+ ))\n+ }\n+ (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))\n+ div id=\"vote-edge-history-region\" {\n+ (edge_history)\n+ }\n+ @if can_post {\n+ form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+ div class=\"vote-thread-picker\" {\n+ label class=\"vote-thread-picker-label\" { \"thread\" }\n+ select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n+ @if thread_tags.is_empty() {\n+ option value=\"vote\" selected { \"#vote\" }\n+ }\n+ @for t in &thread_tags {\n+ @if *t == auto_thread {\n+ option value=(t) selected { \"#\" (t) }\n+ } @else {\n+ option value=(t) { \"#\" (t) }\n+ }\n+ }\n+ }\n+ }\n+ input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n+ input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n+ label class=\"vote-compare-slider-label\" {\n+ span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n+ input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+ aria-valuemin=\"0\" aria-valuemax=\"100\";\n+ span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n+ }\n+ label class=\"vote-explain-label\" { \"reason (required)\" }\n+ textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n+ div id=\"vote-compare-errors\" {}\n+ p { button type=\"submit\" { \"post vote\" } }\n+ }\n+ } @else {\n+ p class=\"muted\" { a href=(login_href_with_next(&next_path)) { \"log in\" } \" to post this vote.\" }\n+ }\n+ }\n+ };\n+\n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n+ let page = layout_full_bleed_chromeless(\n+ &title,\n+ \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen\",\n+ body,\n+ Some(view_count),\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string()).into_response()\n+}\n+\n","role":"user"}],"model":"openai/gpt-chat-latest"}