constitution · epochs · watch · epoch 3

comparison

c_c6f5afec1d36 (tommy-mor) vs c_16438843de8f (tommy-mor)

download prompt · raw event · cmp_0c1b954f15cd2c

council reasoning

~anthropic/claude-sonnet-latest · winner A · 8:2 · permalink

Side A implements a coherent, integrated feature (pool-scoped voting) with real changes to routing, query parsing, HTML templates, and matching test updates, showing deliberate design work across the actual codebase. Side B's diff is oddly structured—adding stray top-level files (event_log.rs, views.rs, Dockerfile, deps.edn, fly.toml) outside any src/ directory, seemingly disconnected/orphaned infra files with a vague 'more seed' message, offering little demonstrable integration or lasting architectural value.

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

A delivers a complete, integrated product feature (pool-scoped voting): optional pool query handling, first-unvoted pair selection from children, constrained next-pair navigation, UI entry point, and pool threading through post-success JS—lasting application design. B is mostly bootstrap/seed (Dockerfile, fly.toml, deps.edn plus standalone event_log/views stubs), which is useful scaffolding but lower durable design density relative to A’s targeted behavior change.

openai/gpt-chat-latest · winner A · 5:2 · permalink

Side A implements a complete, integrated feature: `/vote` gains a `pool` mode, request parsing and validation are extended, next-pair selection is constrained to a pool, UI links are added for voting on children, and the pool context is preserved through post-success navigation. Side B mainly adds deployment/configuration files plus standalone `event_log.rs` and `views.rs` modules that are not wired into the shown application flow, so the demonstrated lasting project impact is smaller.

sides

A — c_c6f5afec1d36 (tommy-mor)

message

[5350388a] Add pool-scoped voting: /vote?pool=<parent> picks pairs from children.

- /vote now accepts an optional `pool` param (parent item path). When
  provided without left/right, it picks the first unvoted pair from the
  pool's children. When provided alongside left/right, it constrains
  "next pair" navigation to siblings within the pool.
- "vote on children" button appears on item pages with ≥2 children,
  linking to /vote?pool=<item>.
- Pool is threaded through VoteComparePost → success JS so in-page
  morph after voting keeps the pool context for next-pair navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff preview

diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 5faa642451d69555cb391974beb0ad22c9355c8c..b79efdb4d52bd445a67f38cbfd61d3507d2b3014 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -193,6 +193,7 @@ async fn dispatch_ui_action(
             ratio_right,
             explanation,
             next,
+            pool,
             form_action,
         } => {
             if form_action != "/ui" {
@@ -239,6 +240,9 @@ async fn dispatch_ui_action(
                         .into_response();
                 }
             };
+            let pool_id = pool.as_deref().and_then(|p| {
+                crate::path_types::ItemId::parse(p.trim()).map(|i| i.normalized_storage())
+            });
             let mut rl = ratio_left.trim().parse::<i32>().unwrap_or(0).max(0);
             let mut rr = ratio_right.trim().parse::<i32>().unwrap_or(0).max(0);
             if rl == 0 && rr == 0 {
@@ -294,6 +298,7 @@ async fn dispatch_ui_action(
                         &thread_tag,
                         &left_id,
                         &right_id,
+                        pool_id.as_ref(),
                         pid.as_str(),
                         post_index,
                     )
diff --git a/server/src/html/garden/pin.rs b/server/src/html/garden/pin.rs
index 5865cfdbc06aeb6555c97380e25591fae6b3d125..1820d7fe7ee167ecbf5ad5124f23b2ab92ffb01f 100644
--- a/server/src/html/garden/pin.rs
+++ b/server/src/html/garden/pin.rs
@@ -79,7 +79,7 @@ pub(super) fn ont_pin_vote_controls(
                         }
                     }
                 } @else {
-                    a class="ont-vote-compare-btn" href=(vote_compare_href(nav, pi, &current, None)) title="Compare and vote" {
+                    a class="ont-vote-compare-btn" href=(vote_compare_href(nav, pi, &current, None, None)) title="Compare and vote" {
                         span class="ont-vote-glyph" aria-hidden="true" { "⚖" }
                         span { "vote" }
                     }
@@ -121,7 +121,7 @@ pub(super) fn child_row_pin_or_vote(
                         if nv == 1 { "" } else { "s" },
                     );
                     @let aria = format!("Vote; {} pairwise {}", nv, if nv == 1 { "vote" } else { "votes" });
-                    a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title=(tip) aria-label=(aria) {
+                    a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None, None)) title=(tip) aria-label=(aria) {
                         span class="ont-garden-vote-glyph" aria-hidden="true" { "⚖" }
                         span class="ont-garden-vote-count" { (format!("{}", nv)) }
                     }
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index 5c3986ca36902f6c9019a4b5590f0b3b9d2cb4ff..bd8cbce059ee789de6e8dc9b6f69f54beee2f994 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -29,6 +29,7 @@ use super::{
     item::{child_depth_from_uri, item_code_label, item_display_path, item_href},
     item_page::{build_item_page_view_model, sibling_nav_markup},
     pin::{child_row_pin_or_vote, ont_pin_vote_controls, pinned_item_from_jar},
+    vote::vote_pool_href,
 };
 
 pub(super) async fn render_scope_view(
@@ -186,12 +187,21 @@ pub(super) async fn render_scope_view(
             }
 
             section class="ont-tab-panel ont-tab-panel-children" {
+                @let total_children = model.child_rankings.component_rankings
+                    .iter().map(|c| c.ranked.len()).sum::<usize>()
+                    + model.child_rankings.unranked_items.len();
                 h3 {
                     "ranked child groups"
                     @if model.child_depth > 1 {
                         " "
                         span class="muted" { (format!("(depth {})", model.child_depth)) }
                     }
+                    @if total_children >= 2 {
+                        " "
+                        a class="ont-vote-children-btn" href=(vote_pool_href(&nav, &model.item)) {
+                            "vote on children"
+                        }
+                    }
                 }
                 @if model.child_rankings.component_rankings.is_empty() {
                     p class="muted" { "no voted pairs yet in this scope" }
diff --git a/server/src/html/garden/tests.rs b/server/src/html/garden/tests.rs
index 6900a6795bcde866a176722149d6378e5127b4c3..c2036fca7752aef63d260e36cfe9649f63b5c550 100644
--- a/server/src/html/garden/tests.rs
+++ b/server/src/html/garden/tests.rs
@@ -105,7 +105,7 @@ fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() {
     let content = content_for_garden_view(&reduced, &ScopeId::Public);
     let a = ItemId::parse("~/topic/a").unwrap().normalized_storage();
     let b = ItemId::parse("~/topic/b").unwrap().normalized_storage();
-    let next = suggest_next_vote_pair(content, &a, &b).expect("next sibling pair");
+    let next = suggest_next_vote_pair(content, &a, &b, None).expect("next sibling pair");
     assert_ne!(
         canonical_edge_items(&next.0, &next.1),
         canonical_edge_items(&a, &b)
diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs
index 1d7fc8aa7436bfa9c1186dfd940a8d751ab7e088..2682cfcbd2f834b56459a02831aa225cffe67c58 100644
--- a/server/src/html/garden/vote.rs
+++ b/server/src/html/garden/vote.rs
@@ -211,14 +211,15 @@ pub(crate) async fn vote_compare_post_success_js(
     _thread_tag: &str,
     left: &ItemId,
     right: &ItemId,
+    pool: Option<&ItemId>,
     _post_id: &str,
     _post_idx: Option<usize>,
 ) -> String {
     let reduced = state.reduced.read().await;
     let content = content_for_garden_view(&reduced, &nav.scope());
     let edge_history = vote_edge_history_markup(content, left, right);
-    let next_pair = suggest_next_vote_pair(content, left, right);
-    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref());
+    let next_pair = suggest_next_vote_pair(content, left, right, pool);
+    let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);
     drop(reduced);
     JsBuilder::new()
         .morph_inner_selector("#vote-edge-history-region", edge_history)
@@ -231,27 +232,39 @@ pub(super) fn vote_compare_href(
     left: &ItemId,
     right: &ItemId,
     thread_override: Option<&str>,
+    pool: Option<&ItemId>,
 ) -> String {
     let left_q = urlencoding::encode(left.as_str());
     let right_q = urlencoding::encode(right.as_str());
-    let base = format!(
+    let mut base = format!(
         "{}/vote?left={}&right={}",
         nav.room_path_prefix_for_vote_compare(),
         left_q,
         right_q
     );
     if let Some(t) = thread_override.filter(|s| !s.is_empty()) {
-        format!("{}&thread={}", base, urlencoding::encode(t))
-    } else {
-        base
+        base = format!("{}&thread={}", base, urlencoding::encode(t));
+    }
+    if let Some(p) = pool {
+        base = format!("{}&pool={}", base, urlencoding::encode(p.as_str()));
     }
+    base
+}
+
+pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {
+    format!(
+        "{}/vote?pool={}",
+        nav.room_path_prefix_for_vote_compare(),
+        urlencoding::encode(pool_item_str)
+    )
 }
 
 fn vote_compare_nav_markup(
     nav: &ThreadNav,
     next_pair: Option<&(ItemId, ItemId)>,
+    pool: Option<&ItemId>,
 ) -> maud::Markup {
-    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None));
+    let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));
     html! {
         div class="vote-compare-nav" {
             @if let Some(href) = &next_pair_href {
@@ -267,8 +280,15 @@ pub(super) fn suggest_next_vote_pair(
     content: &ContentState,
     current_left: &ItemId,
     current_right: &ItemId,
+    pool_parent: Option<&ItemId>,
 ) -> Option<(ItemId, ItemId)> {
-    let pool: Vec<ItemId> = if current_left.parent().as_ref().map(|p| p.as_str())
+    let pool: Vec<ItemId> = if let Some(parent) = pool_parent {
+        content
+            .item_children
+            .get(parent)
+            .map(|s| s.iter().cloned().collect())
+            .unwrap_or_default()
+    } else if current_left.parent().as_ref().map(|p| p.as_str())
         == current_right.parent().as_ref().map(|p| p.as_str())
     {
         current_left
@@ -322,10 +342,14 @@ pub(super) fn vote_compare_item_card(
 }
 #[derive(Debug, Deserialize)]
 pub struct VoteCompareQuery {
-    pub left: String,
-    pub right: String,
+    #[serde(default)]
+    pub left: Option<String>,
+    #[serde(default)]
+    pub right: Option<String>,
     #[serde(default)]
     pub thread: Option<String>,
+    #[serde(default)]
+    pub pool: Option<String>,
 }
 
 /// Public pairwise vote UI — `/vote?left=&right=&thread=`.
@@ -376,17 +400,53 @@ async fn vote_compare_inner(
     jar: CookieJar,
     uri: Uri,
 ) -> axum::response::Response {
-    let left = match ItemId::parse(q.left.trim()) {
-        Some(i) => i.normalized_storage(),
-        None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
+    let pool_id: Option<ItemId> = match q.pool.as_deref() {
+        Some(p) => match ItemId::parse(p.trim()) {
+            Some(i) => Some(i.normalized_storage()),
+            None => return (StatusCode::BAD_REQUEST, "bad pool item").into_response(),
+        },
+        None => None,
     };
-    let right = match ItemId::parse(q.right.trim()) {
-        Some(i) => i.normalized_storage(),
-        None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
+
+    let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {
+        (Some(l), Some(r)) => {
+            let left = match ItemId::parse(l.trim()) {
+                Some(i) => i.normalized_storage(),
+                None => return (StatusCode::NOT_FOUND, "bad left item").into_response(),
+            };
+            let right = match ItemId::parse(r.trim()) {
+                Some(i) => i.normalized_storage(),
+                None => return (StatusCode::NOT_FOUND, "bad right item").into_response(),
+            };
+            if left == right {
+                return (StatusCode::BAD_REQUEST, "items must differ").into_response();
+            }
+            (left, right)
+        }
+        (None, None) => {
+            let Some(pool) = pool_id.as_ref() else {
+                return (StatusCode::BAD_REQUEST, "provide left+right or pool").into_response();
+            };
+            let reduced = state.reduced.read().await;
+            let content = content_for_garden_view(&reduced, &nav.scope());
+            let children: Vec<ItemId> = content
+                .item_children
+                .get(pool)
+                .map(|s| s.iter().cloned().collect())
+                .unwrap_or_default();
+            if children.len() < 2 {
+                drop(reduced);
+                return (StatusCode::BAD_REQUEST, "pool has fewer than 2 children to compare").into_response();
+            }
+            let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);
+            drop(reduced);
+            match pair {
+                Some(p) => p,
+                None => return (StatusCode::BAD_REQUEST, "no pairs available in pool").into_response(),
+            }
+        }
+        _ => return (StatusCode::BAD_REQUEST, "provide both left and right, or just pool").into_response(),
     };
-    if left == right {
-        return (StatusCode::BAD_REQUEST, "items must differ").into_response();
-    }
 
     let reduced = state.reduced.read().await;
     let content = content_for_garden_view(&reduced, &nav.scope());
@@ -409,7 +469,7 @@ async fn vote_compare_inner(
     let left_body = content.item_bodies.get(&left).cloned();
     let right_body = content.item_bodies.get(&right).cloned();
     let item_bodies_for_cards = content.item_bodies.

… preview truncated; 1,501 characters omitted

download full diff A

B — c_16438843de8f (tommy-mor)

message

[4cd0d15d] more seed

diff preview

diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..9cb07c60cb0da063f747cfbf1b3b876ecb8ba03e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# time 0.3.47+ requires Rust 1.88 (edition 2024)
+FROM rust:1.88-slim as builder
+
+WORKDIR /build
+
+RUN apt-get update && \
+    apt-get install -y pkg-config libssl-dev && \
+    rm -rf /var/lib/apt/lists/*
+
+# Copy source and build. (Keep it simple to avoid remote build cache oddities.)
+COPY . .
+RUN cargo build --release --package slugsocial-server
+
+FROM debian:bookworm-slim
+
+RUN apt-get update && \
+    apt-get install -y ca-certificates && \
+    rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY --from=builder /build/target/release/slugsocial-server /app/slugsocial-server
+
+# Create data directory for persistent volume
+RUN mkdir -p /data
+
+ENV SLUG_DATA_DIR=/data
+ENV SLUG_EVENT_LOG=/data/events.jsonl
+ENV PORT=8080
+
+EXPOSE 8080
+
+CMD ["/app/slugsocial-server"]
+
diff --git a/deps.edn b/deps.edn
new file mode 100644
index 0000000000000000000000000000000000000000..0bf892d44f491cb2313e01ae8a942c3097c52948
--- /dev/null
+++ b/deps.edn
@@ -0,0 +1,10 @@
+{:paths ["." "test"]
+ :deps {cheshire/cheshire {:mvn/version "5.13.0"}
+        http-kit/http-kit {:mvn/version "2.8.0"}
+        babashka/fs {:mvn/version "0.5.32"}
+        babashka/process {:mvn/version "0.6.25"}
+        com.blockether/spel {:mvn/version "0.7.11"}}
+ :aliases
+ {:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
+                        lambdaisland/kaocha-junit-xml {:mvn/version "1.17.101"}}
+          :main-opts ["-m" "kaocha.runner"]}}}
diff --git a/event_log.rs b/event_log.rs
new file mode 100644
index 0000000000000000000000000000000000000000..eaae0d495e43a45d6590603892265a62cc92906e
--- /dev/null
+++ b/event_log.rs
@@ -0,0 +1,83 @@
+use std::path::{Path, PathBuf};
+
+use tokio::{
+    fs::{self, OpenOptions},
+    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+};
+
+use crate::events::Event;
+
+#[derive(Debug, thiserror::Error)]
+pub enum EventLogError {
+    #[error("io error: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("json error: {0}")]
+    Json(#[from] serde_json::Error),
+}
+
+#[derive(Debug, Clone)]
+pub struct EventLog {
+    path: PathBuf,
+}
+
+impl EventLog {
+    pub fn new(path: impl Into<PathBuf>) -> Self {
+        Self { path: path.into() }
+    }
+
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {
+        if let Some(parent) = self.path.parent() {
+            fs::create_dir_all(parent).await?;
+        }
+        Ok(())
+    }
+
+    pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {
+        self.ensure_parent_dir().await?;
+        let mut f: tokio::fs::File = OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open(&self.path)
+            .await?;
+
+        let mut line = serde_json::to_string(event)?;
+        line.push('\n');
+        f.write_all(line.as_bytes()).await?;
+        f.flush().await?;
+        Ok(())
+    }
+
+    /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`.
+    pub async fn load_all(&self) -> Result<(Vec<Event>, Vec<(usize, String)>), EventLogError> {
+        if !fs::try_exists(&self.path).await? {
+            return Ok((vec![], vec![]));
+        }
+
+        let f = fs::File::open(&self.path).await?;
+        let mut reader = BufReader::new(f).lines();
+
+        let mut events = Vec::new();
+        let mut bad_lines = Vec::new();
+
+        let mut line_no: usize = 0;
+        while let Some(line) = reader.next_line().await? {
+            line_no += 1;
+            let trimmed = line.trim();
+            if trimmed.is_empty() {
+                continue;
+            }
+            match serde_json::from_str::<Event>(trimmed) {
+                Ok(ev) => events.push(ev),
+                Err(_) => bad_lines.push((line_no, line)),
+            }
+        }
+
+        Ok((events, bad_lines))
+    }
+}
+
+
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000000000000000000000000000000000000..bbb9345e527452db1d87a549213645c195eae5fc
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,42 @@
+app = "slugsocial"
+primary_region = "iad"
+
+[build]
+  dockerfile = "Dockerfile"
+
+[env]
+  SLUG_DATA_DIR = "/data"
+  SLUG_EVENT_LOG = "/data/events.jsonl"
+  PORT = "8080"
+
+[[services]]
+  internal_port = 8080
+  protocol = "tcp"
+
+  [[services.ports]]
+    port = 80
+    handlers = ["http"]
+    force_https = true
+
+  [[services.ports]]
+    port = 443
+    handlers = ["tls", "http"]
+
+  [services.concurrency]
+    type = "connections"
+    hard_limit = 1000
+    soft_limit = 500
+
+  [[services.http_checks]]
+    interval = "10s"
+    timeout = "2s"
+    grace_period = "5s"
+    method = "GET"
+    path = "/healthz"
+    protocol = "http"
+    tls_skip_verify = false
+
+[[mounts]]
+  source = "slugsocial_data"
+  destination = "/data"
+
diff --git a/views.rs b/views.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d4f0ffc49475f014698b4da0de6f476884430813
--- /dev/null
+++ b/views.rs
@@ -0,0 +1,63 @@
+use std::{
+    collections::HashMap,
+    sync::{Arc, Mutex},
+};
+use tokio::sync::mpsc;
+
+type CountMap = Arc<Mutex<HashMap<String, u64>>>;
+
+#[derive(Clone)]
+pub struct ViewStore {
+    counts: CountMap,
+    flush_tx: mpsc::Sender<()>,
+}
+
+impl ViewStore {
+    pub fn new(json_path: &str) -> Self {
+        // Load existing counts from disk on startup (best-effort)
+        let initial: HashMap<String, u64> = std::fs::read_to_string(json_path)
+            .ok()
+            .and_then(|s| serde_json::from_str(&s).ok())
+            .unwrap_or_default();
+
+        let counts: CountMap = Arc::new(Mutex::new(initial));
+        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
+        let path = json_path.to_string();
+
+        let counts_for_writer = counts.clone();
+        tokio::spawn(async move {
+            while flush_rx.recv().await.is_some() {
+                while flush_rx.try_recv().is_ok() {}
+
+                let snapshot: HashMap<String, u64> = {
+                    counts_for_writer.lock().unwrap().clone()
+                };
+
+                let path = path.clone();
+                let _ = tokio::task::spawn_blocking(move || {
+                    if let Ok(json) = serde_json::to_string(&snapshot) {
+                        let tmp = format!("{path}.tmp");
+                        if std::fs::write(&tmp, &json).is_ok() {
+                            let _ = std::fs::rename(&tmp, &path);
+                        }
+                    }
+                })
+                .await;
+            }
+        });
+
+        Self { counts, flush_tx }
+    }
+
+    pub fn increment(&self, path: String) {
+        {
+            let mut map = self.counts.lock().unwrap();
+            *map.entry(path).or_insert(0) += 1;
+        }
+        let _ = self.flush_tx.try_send(());
+    }
+
+    pub fn get_views(&self, path: &str) -> u64 {
+        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
+    }
+}

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.