constitution · epochs · watch · epoch 3

comparison

c_77729db919ab (tommy-mor) vs c_4772ee88dbe3 (tommy-mor)

download prompt · raw event · cmp_8687b4c131b6c7

council reasoning

~anthropic/claude-sonnet-latest · winner A · 6:4 · permalink

Side A introduces a genuinely new, well-tested subsystem (composable url_rules engine with per-domain canonicalization, breadcrumb/parent logic, alias detection) that fixes real bugs (e.g. phantom 'comments' breadcrumbs) and is directly tied to the documented ItemId-as-canonical-URL design, with dozens of unit tests validating behavior. Side B mixes useful dead-code removal (demo counter) with a non-trivial async settlement/batching architecture and ranking cache, but adds no tests for the new concurrency-sensitive worker, making its correctness and lasting value less certain despite the performance intent.

~x-ai/grok-latest · winner A · 3:1 · permalink

A redesigns core identity around full canonical URLs and adds a lasting, composable url_rules engine (parent/breadcrumb hierarchy, Reddit/YouTube normalization) wired through storage, projection, and Reddit mapping. B’s lasting piece is a useful settlement worker plus ranking cache/read-lock cleanup, but much of the diff is removing demo-counter scaffolding rather than foundational model work.

openai/gpt-chat-latest · winner A · 4:3 · permalink

Side A introduces a durable URL identity and canonicalization subsystem (`url_rules` with composable normalization, breadcrumb/parent logic, `ItemId` integration, replay parsing, and widespread migration to canonical `https://` IDs), improving how entities are identified and stored across the project. Side B makes meaningful architectural improvements by removing demo-only code and adding a settlement worker plus cached ranking computation, but its main impact is performance and cleanup, whereas Side A establishes a broader foundational data model that affects persistence, replay, parsing, and navigation.

sides

A — c_77729db919ab (tommy-mor)

message

[239c074b] url schema stuff

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 426a88e7c1da54fe0a28c5c76fa4e1f1bc117fcf..e60b9ba6012593361ef10e8fdd9439cd9932e09b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,3 +58,4 @@ Use **tmux** for `cargo run --package sorter2-server` (dev server). Rebuild afte
 
 - First `cargo test` / `cargo build --release` is slow; Clojure smoke test always does a release build.
 - `legacy/` and `ideas/` are not part of the workspace build.
+- **ItemId** for web URLs is a canonical full URL (`https://reddit.com/r/rust`). Rules live in [`server/src/url_rules/`](server/src/url_rules/) (composable Rust, not a config DSL). After changing canonicalization rules, rebuild the projection: `cargo run --package sorter2-server -- replay-index`.
diff --git a/Cargo.lock b/Cargo.lock
index 0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4..49a908ef935c430dbe63c6a28d8a24e38b489486 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1951,6 +1951,7 @@ dependencies = [
  "tower-http 0.5.2",
  "tracing",
  "tracing-subscriber",
+ "url",
  "urlencoding",
 ]
 
diff --git a/REPLAY.sh b/REPLAY.sh
new file mode 100755
index 0000000000000000000000000000000000000000..f2dbd8aea60c02d2feef74805f7ef5c2b7022537
--- /dev/null
+++ b/REPLAY.sh
@@ -0,0 +1,2 @@
+cargo run --package sorter2-server -- replay-index
+
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 27f552c20b97ef28cdde4cb6b1a4980375135111..ad4912791aff59fb1d3293f66ad381ae618cd60b 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -24,6 +24,7 @@ async-stream = "0.3"
 futures-util = { version = "0.3", default-features = false, features = ["std"] }
 rand = "0.8"
 urlencoding = "2"
+url = "2"
 durable = { path = "../durable" }
 
 [dev-dependencies]
diff --git a/server/src/entity_store.rs b/server/src/entity_store.rs
index d5d17c3676e4a8ddec998e9f5a9dbafe9c2d9d0e..d29f39aecca6f12cdcf263cf77c3654eb4ee6cfa 100644
--- a/server/src/entity_store.rs
+++ b/server/src/entity_store.rs
@@ -124,7 +124,7 @@ mod tests {
     fn round_trip_payload() {
         let tmp = tempfile::tempdir().unwrap();
         let store = EntityStore::open(tmp.path()).unwrap();
-        let id = ItemId::parse("reddit.com/r/rust").unwrap();
+        let id = ItemId::from_url("https://reddit.com/r/rust").unwrap();
         let payload = json!({"kind": "t5", "data": {"display_name": "rust"}});
 
         store.put(&id, &payload).unwrap();
diff --git a/server/src/event_log.rs b/server/src/event_log.rs
index 36f5b406084065b608735987cdb483c236e03081..2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36 100644
--- a/server/src/event_log.rs
+++ b/server/src/event_log.rs
@@ -199,7 +199,7 @@ mod tests {
         log.append(&sample_record(
             1,
             Event::NodeEnsured {
-                id: "reddit.com/r/rust".into(),
+                id: "https://reddit.com/r/rust".into(),
             },
         ))
         .await
@@ -237,7 +237,7 @@ mod tests {
         let path = tmp.path().join("events.jsonl");
         let log = EventLog::new(&path);
         let event = Event::NodeEnsured {
-            id: "reddit.com/r/rust".into(),
+            id: "https://reddit.com/r/rust".into(),
         };
         log.append(&sample_record(1, event)).await.unwrap();
 
@@ -255,7 +255,7 @@ mod tests {
         let path = tmp.path().join("events.jsonl");
         std::fs::write(
             &path,
-            r#"{"type":"node_ensured","id":"reddit.com/r/rust"}
+            r#"{"type":"node_ensured","id":"https://reddit.com/r/rust"}
 {"schema":1,"seq":1,"ts":1,"event":{"type":"vote_recorded","ts":1,"a":"a","b":"b","ratio_left":2,"ratio_right":1,"scope":""}}
 "#,
         )
@@ -295,7 +295,7 @@ mod tests {
         log.append(&sample_record(
             1,
             Event::NodeEnsured {
-                id: "reddit.com/r/rust".into(),
+                id: "https://reddit.com/r/rust".into(),
             },
         ))
         .await
@@ -303,7 +303,7 @@ mod tests {
         log.append(&sample_record(
             3,
             Event::NodeEnsured {
-                id: "reddit.com/r/python".into(),
+                id: "https://reddit.com/r/python".into(),
             },
         ))
         .await
diff --git a/server/src/journal.rs b/server/src/journal.rs
index 521a108019de1ea870d14c4fafbfe572c20ce0de..50bc89f976edb82b7b0e49e954a8eccbbe82bf87 100644
--- a/server/src/journal.rs
+++ b/server/src/journal.rs
@@ -141,10 +141,10 @@ mod tests {
         let j2 = journal.clone();
         let (r1, r2) = tokio::join!(
             j1.append(Event::NodeEnsured {
-                id: "reddit.com/r/rust".into(),
+                id: "https://reddit.com/r/rust".into(),
             }),
             j2.append(Event::NodeEnsured {
-                id: "reddit.com/r/python".into(),
+                id: "https://reddit.com/r/python".into(),
             }),
         );
         r1.unwrap();
@@ -153,10 +153,10 @@ mod tests {
         assert_eq!(projection_store.last_applied_event_count().unwrap(), 2);
         let tree = projection_store.load_tree().unwrap();
         assert!(tree
-            .get(&ItemId::parse("reddit.com/r/rust").unwrap())
+            .get(&ItemId::parse("https://reddit.com/r/rust").unwrap())
             .is_some());
         assert!(tree
-            .get(&ItemId::parse("reddit.com/r/python").unwrap())
+            .get(&ItemId::parse("https://reddit.com/r/python").unwrap())
             .is_some());
     }
 
@@ -170,7 +170,7 @@ mod tests {
                 1,
                 1,
                 Event::NodeEnsured {
-                    id: "reddit.com/r/rust".into(),
+                    id: "https://reddit.com/r/rust".into(),
                 },
             ))
             .await
@@ -186,7 +186,7 @@ mod tests {
                 1,
                 1,
                 Event::NodeEnsured {
-                    id: "reddit.com/r/rust".into(),
+                    id: "https://reddit.com/r/rust".into(),
                 },
             )],
         )
@@ -202,7 +202,7 @@ mod tests {
         );
         journal
             .append(Event::NodeEnsured {
-                id: "reddit.com/r/python".into(),
+                id: "https://reddit.com/r/python".into(),
             })
             .await
             .unwrap();
@@ -227,13 +227,13 @@ mod tests {
         journal
             .append_many(vec![
                 Event::NodeEnsured {
-                    id: "reddit.com/r/rust".into(),
+                    id: "https://reddit.com/r/rust".into(),
                 },
                 Event::NodeEnsured {
-                    id: "reddit.com/r/python".into(),
+                    id: "https://reddit.com/r/python".into(),
                 },
                 Event::NodeEnsured {
-                    id: "reddit.com/r/clojure".into(),
+                    id: "https://reddit.com/r/clojure".into(),
                 },
             ])
             .await
@@ -245,7 +245,7 @@ mod tests {
         assert_eq!(projection_store.last_applied_event_count().unwrap(), 3);
         let tree = projection_store.load_tree().unwrap();
         assert!(tree
-            .get(&ItemId::parse("reddit.com/r/clojure").unwrap())
+            .get(&ItemId::parse("https://reddit.com/r/clojure").unwrap())
             .is_some());
     }
 }
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 9bd5f76fd1406b9b1be4c272f4ba8647edde2678..5c02c8e704e4664453bad75d819df8a067668176 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -9,6 +9,7 @@ pub mod journal;
 pub mod pair;
 pub mod parser;
 pub mod path_types;
+pub mod url_rules;
 pub mod projection_apply;
 pub mod projection_store;
 pub mod ranking;
diff --git a/server/src/pair.rs b/server/src/pair.rs
index 43f780ba6ea6ce1cdc2e1f4cbb252ba8a10684b9..815a97b80e3e9f348e0937a4f147f2862018edb0 100644
--- a/server/src/pair.rs
+++ b/server/src/pair.rs
@@ -381,42 +381,42 @@ mod tests {
 
     #[test]
     fn suggest_prefers_unvoted_pair() {
-        let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+        let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
         let mut tree = seed_children(
             &parent,
             &[
-                "reddit.com/r/rust/a",
-                "reddit.com/r/rust/b",
-                "reddit.com/r/rust/c",
+                "https://reddit.com/r/rust/a",
+                "https://reddit.com/r/rust/b",
+                "https://reddit.com/r/rust/c",
             ],
         );
         let vote =
-            VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
         tree.apply_vote(&parent, vote);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
         let pool = children_of(&tree, &parent);
         let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
-        let voted_ab = (l.as_str() == "reddit.com/r/rust/a" && r.as_str() == "reddit.com/r/rust/b")
-            || (l.as_str() == "reddit.com/r/rust/b" && r.as_str() == "reddit.com/r/rust/a");
+        let voted_ab = (l.as_str() == "https://reddit.com/r/rust/a" && r.as_str() == "https://reddit.com/r/rust/b")
+            || (l.as_str() == "https://reddit.com/r/rust/b" && r.as_str() == "https://reddit.com/r/rust/a");
         assert!(!voted_ab);
     }
 
     #[test]
     fn suggest_bridges_separate_components() {
-        let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+        let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
         let mut tree = seed_children(
             &parent,
             &[
-                "reddit.com/r/rust/a",
-                "reddit.com/r/rust/b",
-                "reddit.com/r/rust/c",
-                "reddit.com/r/rust/d",
+                "https://reddit.com/r/rust/a",
+                "https://reddit.com/r/rust/b",
+                "https://reddit.com/r/rust/c",
+                "https://reddit.com/r/rust/d",
             ],
         );
         let ab =
-            VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+            VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
         let cd =
-            VoteData::from_recorded(2, "reddit.com/r/rust/c", "reddit.com/r/rust/d", 2, 1).unwrap();
+            VoteData::from_recorded(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1).unwrap();
         tree.apply_vote(&parent, ab);
         tree.apply_vote(&parent, cd);
         let group = tree.get(&parent).unwrap().local_ranking.clone();
@@ -424,37 +424,37 @@ mod tests {
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
         let chosen = pair_set(&pair);
         let from_ab =
-            chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b");
+            chosen.contains("https://reddit.com/r/rust/a") || chosen.contains("https://reddit.com/r/rust/b");
         let from_cd =
-            chosen.contains("reddit.com/r/rust/c") || chosen.contains("reddit.com/r/rust/d");
+            chosen.contains("https://reddit.com/r/rust/c") || chosen.contains("https://reddit.com/r/rust/d");
         assert!(from_ab && from_cd, "expected bridge pair, got {:?}", chosen);
     }
 
     #[test]
     fn suggest_prefers_attach_over_isolate_pair_among_many_unranked() {
-        let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+        let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
         let mut tree = seed_children(
             &parent,
             &[
-                "reddit.com/r/rust/a",
-                "reddit.com/r/rust/b",
-                "reddit.com/r/rust/c",
-                "reddit.com/r/rust/d",
-                "reddit.com/r/rust/e",
+                "https://reddit.com/r/rust/a",
+                "https://reddit.com/r/rust/b",
+                "https://reddit.com/r/rust/c",
+                "https://reddit.com/r/

… preview truncated; 51,799 characters omitted

download full diff A

B — c_4772ee88dbe3 (tommy-mor)

message

[07715165] nice

diff preview

diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index c3c62a76f424010d77a6090c84dd0b82098f573e..da2536112faea313352624cf2ce0ddd0ab3377c1 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,7 @@ use axum::{
 use std::collections::HashMap;
 
 use crate::{
-    html::{demo_counter_panel, js_string_literal, ranking_panel, JsBuilder},
+    html::{js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
     parser_render::parser_panel_morph,
     state::AppState,
@@ -35,13 +35,6 @@ pub async fn post_ui_html(
     };
 
     match action {
-        HtmlUiAction::BumpDemoCounter => {
-            let count = state.bump_demo_counter().await;
-            let panel = demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref());
-            JsBuilder::new()
-                .morph_selector("#demo-counter-panel", panel)
-                .into_response()
-        }
         HtmlUiAction::RecordVote {
             a,
             b,
@@ -54,8 +47,8 @@ pub async fn post_ui_html(
             {
                 return ui_js_warn(&e).into_response();
             }
-            let mut group = state.group.write().await;
-            let panel = ranking_panel(&mut group);
+            let group = state.group.read().await;
+            let panel = ranking_panel(&group);
             JsBuilder::new()
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
@@ -87,20 +80,6 @@ mod tests {
         assert!(matches!(err, HtmlUiParseError::MissingRpc));
     }
 
-    #[test]
-    fn bump_action_deserializes() {
-        let template = serde_json::json!({ "action": "bump_demo_counter" });
-        let mut form = HashMap::new();
-        form.insert(
-            UI_RPC_FIELD.to_string(),
-            serde_json::to_string(&template).unwrap(),
-        );
-        assert_eq!(
-            parse_html_ui_from_form(&form).unwrap(),
-            HtmlUiAction::BumpDemoCounter
-        );
-    }
-
     #[test]
     fn record_vote_action_deserializes() {
         let template = serde_json::json!({
diff --git a/server/src/events.rs b/server/src/events.rs
index b969242534e184d4f0a689543a479670b08a18df..eff80aef0257f706d2341f666e63d6a3d921bf6e 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -5,8 +5,6 @@ use serde::{Deserialize, Serialize};
 pub enum Event {
     /// Page view recorded (path → counter in views.json).
     ViewRecorded { path: String, ts: i64 },
-    /// Demo counter bump from `POST /ui` (persisted in the single JSONL log).
-    DemoCounterBumped { ts: i64, value: u64 },
     /// Pairwise comparison vote (replayed into [`crate::reducer::GroupState`] on boot).
     VoteRecorded {
         ts: i64,
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 5b1d0b5a887d89e7e80796aa7a6c8ed5baaf2782..d69ed962b5c8625bc83c933b1825f2cc1d0868e2 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -13,7 +13,7 @@ use crate::{
     form_template::template_json_compact,
     parser_action::ParserAction,
     parser_render::parser_panel,
-    ranking::ranked_items,
+    ranking::ranked_items_cached,
     reducer::GroupState,
     state::AppState,
     ui_action::UI_RPC_FIELD,
@@ -199,10 +199,8 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
     }
 }
 
-pub fn ranking_panel(group: &mut GroupState) -> Markup {
-    const MAX_ITERS: usize = 10_000;
-    const TOL: f64 = 1e-8;
-    let items = ranked_items(group, MAX_ITERS, TOL);
+pub fn ranking_panel(group: &GroupState) -> Markup {
+    let items = ranked_items_cached(group);
     html! {
         section id="ranking-panel" class="demo-panel" {
             h2 { "Ranking" }
@@ -260,35 +258,6 @@ pub fn vote_panel() -> Markup {
 }
 
 
-pub fn demo_counter_panel(count: u64, event_log_path: &str) -> Markup {
-    let rpc = template_json_compact(&serde_json::json!({ "action": "bump_demo_counter" }))
-        .expect("rpc json");
-    html! {
-        section id="demo-counter-panel" class="demo-panel" {
-            h1 { "sorter2" }
-            p class="muted" {
-                "Pairwise ranking scaffold — votes persist to JSONL and replay on boot."
-            }
-            p class="demo-count" {
-                strong { "Counter: " }
-                span id="demo-count-value" { (count) }
-            }
-            p class="muted small" {
-                "Event log: " code { (event_log_path) }
-            }
-            form method="post" action="/ui" id="demo-bump-form" {
-                input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
-                button type="submit" class="btn-primary" { "Bump (POST /ui → eval JS)" }
-            }
-            p class="muted small" {
-                "Uses hidden "
-                code { "__rpc__" }
-                " JSON + Idiomorph morph — no full page reload."
-            }
-        }
-    }
-}
-
 pub async fn home(
     State(state): State<AppState>,
     jar: CookieJar,
@@ -297,16 +266,15 @@ pub async fn home(
     let path = uri.path().to_string();
     state.views.increment(path.clone());
     let views = state.views.get_views(&path);
-    let count = *state.demo_counter.read().await;
     let theme = theme_from_jar(&jar);
     let theme_next = theme_next_from_uri(&uri);
-    let mut group = state.group.write().await;
+    let group = state.group.read().await;
     let empty_action = ParserAction::suggest(String::new(), None);
     let body = html! {
+        h1 { "sorter2" }
         (parser_panel("", &empty_action))
         (vote_panel())
-        (ranking_panel(&mut group))
-        (demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref()))
+        (ranking_panel(&group))
     };
     layout("sorter2", body, views, theme, &theme_next)
 }
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 6716c5b282e7980a7a0f03d63ad8b25eda61cc55..fa423640d598f4ba97a5885d228e78d7b97f7a22 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -9,6 +9,7 @@ pub mod parser_render;
 pub mod path_types;
 pub mod ranking;
 pub mod reducer;
+pub mod settlement;
 pub mod state;
 pub mod ui_action;
 pub mod views;
diff --git a/server/src/ranking.rs b/server/src/ranking.rs
index 89d3280126a8d8f841721ce8cb63ff735d68752a..2d706762792ba9239bb3f1c2e4974a2fde908013 100644
--- a/server/src/ranking.rs
+++ b/server/src/ranking.rs
@@ -91,6 +91,11 @@ pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64)
 
 pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec<RankedItem> {
     compute_group_ranking(group, max_iters, tol);
+    ranked_items_cached(group)
+}
+
+/// Read cached scores without recomputing (HTTP fast path).
+pub fn ranked_items_cached(group: &GroupState) -> Vec<RankedItem> {
     let mut items: Vec<RankedItem> = group
         .idx_to_item
         .iter()
@@ -105,7 +110,12 @@ pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec<R
     items
 }
 
-fn compute_scores_from_edges(n: usize, edges: impl Iterator<Item = ((usize, usize), f64)>, max_iters: usize, tol: f64) -> Vec<f64> {
+pub fn compute_scores_from_edges(
+    n: usize,
+    edges: impl Iterator<Item = ((usize, usize), f64)>,
+    max_iters: usize,
+    tol: f64,
+) -> Vec<f64> {
     if n == 0 {
         return vec![];
     }
diff --git a/server/src/settlement.rs b/server/src/settlement.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1f722ceaea62cda22c28ab71551f259fbf049b81
--- /dev/null
+++ b/server/src/settlement.rs
@@ -0,0 +1,114 @@
+use std::sync::Arc;
+
+use tokio::sync::{mpsc, oneshot, RwLock};
+
+use crate::{
+    event_log::EventLog,
+    events::Event,
+    ranking::compute_scores_from_edges,
+    reducer::{GroupState, VoteData},
+};
+
+const MAX_ITERS: usize = 10_000;
+const TOL: f64 = 1e-8;
+
+pub struct SettlementCommand {
+    pub vote: VoteData,
+    pub event: Event,
+    pub reply: oneshot::Sender<Result<(), String>>,
+}
+
+#[derive(Clone)]
+pub struct SettlementClient {
+    tx: mpsc::Sender<SettlementCommand>,
+}
+
+impl SettlementClient {
+    pub fn spawn(group: Arc<RwLock<GroupState>>, event_log: Arc<EventLog>) -> Self {
+        let (tx, rx) = mpsc::channel(64);
+        tokio::spawn(settlement_worker(rx, group, event_log));
+        Self { tx }
+    }
+
+    pub async fn record_vote(&self, vote: VoteData, event: Event) -> Result<(), String> {
+        let (reply, rx) = oneshot::channel();
+        self.tx
+            .send(SettlementCommand {
+                vote,
+                event,
+                reply,
+            })
+            .await
+            .map_err(|_| "settlement worker stopped".to_string())?;
+        rx.await
+            .map_err(|_| "settlement worker stopped".to_string())?
+    }
+}
+
+async fn settlement_worker(
+    mut rx: mpsc::Receiver<SettlementCommand>,
+    group: Arc<RwLock<GroupState>>,
+    event_log: Arc<EventLog>,
+) {
+    while let Some(first) = rx.recv().await {
+        let mut batch = vec![first];
+        while let Ok(more) = rx.try_recv() {
+            batch.push(more);
+        }
+
+        let mut disk_err: Option<String> = None;
+        for cmd in &batch {
+            if let Err(e) = event_log.append(&cmd.event).await {
+                disk_err = Some(e.to_string());
+                break;
+            }
+        }
+
+        if let Some(err) = disk_err {
+            for cmd in batch {
+                let _ = cmd.reply.send(Err(err.clone()));
+            }
+            continue;
+        }
+
+        let (edges, n) = {
+            let mut w = group.write().await;
+            for cmd in &batch {
+                w.apply_vote(cmd.vote.clone());
+            }
+            (w.edges.clone(), w.idx_to_item.len())
+        };
+
+        let new_scores = compute_scores_from_edges(
+            n,
+            edges.iter().map(|(&k, &v)| (k, v)),
+            MAX_ITERS,
+            TOL,
+        );
+
+        {
+            let mut w = group.write().await;
+            w.cached_scores = new_scores;
+            w.dirty = false;
+        }
+
+        for cmd in batch {
+            let _ = cmd.reply.send(Ok(()));
+        }
+    }
+}
+
+/// Compute ranking cache from current in-memory edges (startup replay only).
+pub fn warm_ranking_cache(group: &mut GroupState) {
+    if !group.dirty {
+        return;
+    }
+    let n = group.idx_to_item.len();
+    group.cached_scores = compute_scores_from_edges(
+        n,
+        group.edges.iter().map(|(&k, &v)| (k, v)),
+        MAX_ITERS,
+        TOL,
+    );
+    group.dirty = false;
+}
diff --git a/server/src/state.rs b/server/src/state.rs
index 8ec9902e2ecc31cf8208f7ad6365891dc5537eed..1922541a4064c2de1df2d993a461cae783320e05 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -6,6 +6,7 @@ use crate::{
     event_log::EventLog,
     events::Event,
     reducer::{GroupState, VoteData},
+    settlement::{warm_ranking_cache, SettlementClient},
     views::ViewStore,
 };
 
@@ -38,8 +39,8 @@ pub struct AppState {
     pub cfg: Arc<AppConfig>,
     pub event_log: Arc<EventLog>,
     pub views: ViewStore,
-    pub demo_counter: Arc<RwLock<u64>>,
     pub group: Arc<RwLock<GroupState>>,
+    settlement: SettlementClient,
 }
 
 impl AppState {
@@ -48,14 +49,10 @@ impl AppState {
         let views_path = format!("{}/views.json", cfg.data_dir);
         let views = ViewStore::new(&views_path);
 
-        let mut demo_counter: u64 = 0;
         let mut group = GroupState::new();
         if let Ok((events, _)) = event_log.load_all().await {
             for ev in events {
                 match ev {
-                    Event::DemoCounterBumped { value, .. } => {
-                        demo_counter = demo_counter.max(value);
-                    }
                     Event::VoteRecorded {
                         ts,
                         a,
@@ -74,30 +71,20 @@ impl AppState {
             }
         }
 
+       

… preview truncated; 6,096 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.