Side A removes dead demo-counter scaffolding and, more importantly, introduces a real architectural improvement: an async settlement worker (settlement.rs) that batches vote writes and caches ranking scores, decoupling expensive Bradley-Terry recomputation from the request path and switching ranking_panel to a read lock. Side B is a much smaller, purely cosmetic fix (rank row color gradient using score-based min-max normalization instead of list position) that improves UI polish but has far less lasting architectural or correctness impact.
constitution · epochs · watch · epoch 3
c_4772ee88dbe3 (tommy-mor) vs c_66eb04076a98 (tommy-mor)
download prompt · raw event · cmp_bb13e4054c43bb
council reasoning
A adds a real settlement worker (batched vote apply, JSONL append, score recompute), ranking cache + read-lock fast path, and deletes the demo-counter scaffold—structural correctness for persistence and HTTP latency. B only retunes rank-row colors from list ordinal to min–max score within a group, a small UI polish with local tests and no core data-path change.
Side A makes substantial architectural improvements: it removes the temporary demo-counter feature, introduces a dedicated settlement worker that batches vote persistence and ranking recomputation, adds cached ranking access (`ranked_items_cached`), warms the cache at startup, and switches UI reads from write locks to read locks. Side B is a focused UI enhancement that changes row coloring to use per-group score normalization with good supporting tests, but it is limited to presentation rather than core project behavior or performance.
sides
A — 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 omittedB — c_66eb04076a98 (tommy-mor)
message
[0366806e] Color rank rows by vote mass within each group, not list position. Min–max normalization keeps similar scores visually close while still using the full gradient as groups grow and absolute mass dilutes. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index a58cbbee3490a08a625cb06df06848c59a615d65..4eff2e19ed4d303ff8e80c1eabd8a15b4990e643 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -264,12 +264,19 @@ pub fn scope_theme_style(parent: &ItemId) -> String {
)
}
-fn rank_row_style(parent: &ItemId, ordinal: usize, total: usize) -> String {
- let t = if total <= 1 {
- 0.0
- } else {
- ordinal as f64 / (total - 1) as f64
- };
+/// Map vote mass to gradient position using the group's score range, not raw mass or
+/// list position. Vote mass sums to 1 across the component, so absolute values dilute
+/// as N grows; min–max within the visible list preserves similar scores → similar colors.
+fn score_gradient_t(score: f64, min_score: f64, max_score: f64) -> f64 {
+ let spread = max_score - min_score;
+ if spread < 1e-9 {
+ return 0.5;
+ }
+ ((max_score - score) / spread).clamp(0.0, 1.0)
+}
+
+fn rank_row_style(parent: &ItemId, score: f64, min_score: f64, max_score: f64) -> String {
+ let t = score_gradient_t(score, min_score, max_score);
let base_hue = scope_base_hue(parent);
let hue = (base_hue + 118.0 * t) % 360.0;
let lightness = 0.74 - 0.34 * t;
@@ -295,14 +302,15 @@ fn rank_list(
highlighted: &HashSet<ItemId>,
tree: &GlobalTree,
) -> Markup {
- let group_len = items.len();
+ let min_score = items.iter().map(|r| r.score).fold(f64::INFINITY, f64::min);
+ let max_score = items.iter().map(|r| r.score).fold(f64::NEG_INFINITY, f64::max);
html! {
@if !items.is_empty() {
h3 class="rank-heading muted small" { (label) }
ol class="rank-list" {
@for (i, r) in items.iter().enumerate() {
@let href = item_href(&r.item);
- @let style = rank_row_style(parent, i, group_len);
+ @let style = rank_row_style(parent, r.score, min_score, max_score);
@let class = rank_row_class(&r.item, highlighted);
li class=(class)
data-rank-item=(r.item.as_str())
@@ -517,19 +525,37 @@ pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoRespons
#[cfg(test)]
mod tests {
- use super::{rank_row_style, SORTER_UI_JS};
+ use super::{rank_row_style, score_gradient_t, SORTER_UI_JS};
use crate::path_types::ItemId;
#[test]
- fn rank_row_style_gradients_per_group_not_globally() {
+ fn score_gradient_t_uses_group_range_not_absolute_mass() {
+ assert!((score_gradient_t(0.12, 0.08, 0.12) - 0.0).abs() < 1e-9);
+ assert!((score_gradient_t(0.08, 0.08, 0.12) - 1.0).abs() < 1e-9);
+ // Raw 12% mass would map near the dark end globally; within this group it's the top.
+ assert!(score_gradient_t(0.12, 0.08, 0.12) < score_gradient_t(0.12, 0.0, 1.0));
+ }
+
+ #[test]
+ fn score_gradient_t_similar_scores_similar_t() {
+ let a = score_gradient_t(0.41, 0.20, 0.60);
+ let b = score_gradient_t(0.40, 0.20, 0.60);
+ assert!((a - b).abs() < 0.05);
+ assert!((a - score_gradient_t(0.60, 0.20, 0.60)).abs() > 0.3);
+ }
+
+ #[test]
+ fn score_gradient_t_tied_scores_neutral() {
+ assert!((score_gradient_t(0.25, 0.25, 0.25) - 0.5).abs() < 1e-9);
+ }
+
+ #[test]
+ fn rank_row_style_same_inputs_same_color() {
let parent = ItemId::opaque("test-scope");
- let first_in_four = rank_row_style(&parent, 0, 4);
- let last_in_four = rank_row_style(&parent, 3, 4);
- let first_in_two = rank_row_style(&parent, 0, 2);
- let last_in_two = rank_row_style(&parent, 1, 2);
- assert_eq!(first_in_four, first_in_two);
- assert_eq!(last_in_four, last_in_two);
- assert_ne!(first_in_four, last_in_four);
+ assert_eq!(
+ rank_row_style(&parent, 0.33, 0.20, 0.60),
+ rank_row_style(&parent, 0.33, 0.20, 0.60),
+ );
}
#[test]
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.