constitution · epochs · watch · epoch 3

comparison

c_2f5d9e0370f8 (tommy-mor) vs c_c6f5afec1d36 (tommy-mor)

download prompt · raw event · cmp_dd8bdf728b9b72

council reasoning

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

Side B adds a genuinely new, self-contained capability (pool-scoped voting) with clean, well-scoped code changes and modest test touch-ups, providing real user-facing value with low churn. Side A is a large syntax overhaul (explanation-first votes) that, while intentional, is dominated by mechanical reformatting across dozens of test fixtures and docs, making its value density lower despite the larger diff.

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

A rewrites the core DSL so votes are explanation-first (leading `{...}` then the comparison), with a real parser split (`parse_block_prefixed_statement` / `parse_item_definition_statement`), docs, and end-to-end fixture updates—that is lasting language surface area. B adds useful pool-scoped `/vote?pool=` navigation and a “vote on children” entry point, but it is a contained HTML/vote-flow feature on top of existing compare logic, not a foundational design change.

openai/gpt-chat-latest · winner B · 4:1 · permalink

Side B adds a substantive new capability: pool-scoped voting, including `pool` query handling, automatic pair selection from a parent's children, preservation of pool context across vote navigation, and UI entry points ('vote on children'). Side A primarily refactors the DSL syntax by moving vote explanations before comparisons and updates the parser, documentation, fixtures, and tests to enforce that new format; while extensive, it is largely a syntax migration rather than adding new project functionality.

sides

A — c_2f5d9e0370f8 (tommy-mor)

message

[6bda2635] Use title-first items and explanation-first votes (#135)

* Require block-first sorter DSL statements

Co-authored-by: tommy <thmorriss@gmail.com>

* Use title-first items with explanation-first votes

Co-authored-by: tommy <thmorriss@gmail.com>

* Update garden vote test DSL fixtures

Co-authored-by: tommy <thmorriss@gmail.com>

* Update browser vote DSL payloads

Co-authored-by: tommy <thmorriss@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

diff preview

diff --git a/cli/DSL.txt b/cli/DSL.txt
index 18f69ef25f01583fddf9fc90e077bb2c3fa72bb6..c9b12bf0edaf73ad252f0a202b7fe61e311df50a 100644
--- a/cli/DSL.txt
+++ b/cli/DSL.txt
@@ -18,9 +18,20 @@ Blank lines are preserved to maintain paragraph structure.
 ~/item/a {itembody}
 ~/python { A high-level scripting language }
 
-~/item/a > ~/python { Item A is better because of X. }
-~/python 3:1 ~/go { Python's ecosystem is much richer than Go's. }
-https://example.com/lang = ~/go { They are equally good in this context. }
+{
+Item A is better because of X.
+}
+~/item/a > ~/python
+
+{
+Python's ecosystem is much richer than Go's.
+}
+~/python 3:1 ~/go
+
+{
+They are equally good in this context.
+}
+https://example.com/lang = ~/go
 ```
 
 SYNTAX RULES
@@ -35,7 +46,7 @@ Starts a thread. Tag allows alphanumeric, `-`, `_`, and `/`. Subtitle max 100 ch
 ~/<local-item-path> { description }
 ```
 Defines an ontology item (garden layer). Paths can be nested (e.g. `~/languages/python`).
-A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}`. Can be adjacent (e.g. `~/arrived{ready}`).
+A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}` and follow the item path.
 
 ```sorter
 https://example.com/item { description }
@@ -46,7 +57,10 @@ Canonicalization rules for URLs:
 - `~/` and `https://slug.social/~/` map to the same local item path.
 
 ```sorter
-<item1> <comparison> <item2> { required explanation }
+{
+required explanation
+}
+<item1> <comparison> <item2>
 ```
 Compares two items. The explanation is REQUIRED.
 Comparisons:
@@ -65,7 +79,8 @@ When writing bodies or explanations, you can use braces `{}` and code blocks wit
 3. Single braces: { ... }
 
 ```sorter
-~/code { Here is a block: ```def foo(): return {"a": 1}``` }
+{ Here is a block: ```def foo(): return {"a": 1}``` }
+~/code
 ```
 
 STYLE
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 7ea1c2649cb4a323b46f0bf389025079a9c9ab43..86accba72ecea547215d947fb6552e0ead25687c 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -83,7 +83,8 @@ Item definitions (attaches a description to an item):
   ~/thread/item { description }
 
 Comparisons:
-  ~/thread/item-a 3:1 ~/thread/item-b { reasoning }
+  { reasoning }
+  ~/thread/item-a 3:1 ~/thread/item-b
 
 Ratio formats:
   3:1   left is 3x better than right
@@ -95,8 +96,7 @@ Shorthand:
   <     means 1:2 (right is better)
   =     means 1:1 (equal)
 
-Bodies can attach without whitespace:
-  ~/thread/item{Description here}
+Item bodies follow the item path. Vote explanations come first; the comparison is the verdict line.
 }
 
 You can write any prose in your posts. These won't be part of the garden but only the thread.
@@ -165,7 +165,8 @@ npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-c
 
 ~/languages/python { A high-level language focused on readability. }
 ~/languages/rust { A systems language focused on safety and performance. }
-~/languages/python 2:1 ~/languages/rust { Python has simpler syntax for beginners - fewer symbols, explicit over implicit.  Rust's borrow checker adds cognitive load even for simple programs.  Both are readable once learned, but Python's learning curve is gentler.  }
+{ Python has simpler syntax for beginners - fewer symbols, explicit over implicit.  Rust's borrow checker adds cognitive load even for simple programs.  Both are readable once learned, but Python's learning curve is gentler.  }
+~/languages/python 2:1 ~/languages/rust
 EOF
 
 # See current ranking
diff --git a/ideas/single-thread.md b/ideas/single-thread.md
index 86230fe6119c31045c21dbfcb12311b323c02fa8..0efb7199524cc3b308b1922c03e3a99193e4586c 100644
--- a/ideas/single-thread.md
+++ b/ideas/single-thread.md
@@ -15,7 +15,8 @@ Previously a `.sorter` document could scatter `#tags` throughout:
 ~/languages/rust {A systems language.}
 #tools
 ~/tools/cargo {Rust's build system.}
-~/languages/rust 2:1 ~/tools/cargo {Rust is more foundational than its tooling.}
+{Rust is more foundational than its tooling.}
+~/languages/rust 2:1 ~/tools/cargo
 ```
 
 The system would fan the ingest into both `#languages` and `#tools` — the same
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 9f3c1cc21c2ae157c024a447980a97e190c8f066..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -72,16 +72,16 @@ mod tests {
         apply_ingest(
             &mut reduced,
             1,
-            "~/t/a {a}\n~/t/b {b}\n~/t/a 2:1 ~/t/b {because}\n",
+            "~/t/a {a}\n~/t/b {b}\n{because}\n~/t/a 2:1 ~/t/b\n",
         );
-        let text = "~/t/a 1:1 ~/t/b {equal}\n";
+        let text = "{equal}\n~/t/a 1:1 ~/t/b\n";
         validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();
     }
 
     #[test]
     fn validate_ingest_document_rejects_vote_on_undefined_item() {
         let reduced = ReducerState::default();
-        let text = "~/t/a {x}\n~/t/b 1:1 ~/t/missing {why}\n";
+        let text = "~/t/a {x}\n{why}\n~/t/b 1:1 ~/t/missing\n";
         let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err();
         assert_eq!(err.0, StatusCode::BAD_REQUEST);
         assert!(err.1.contains("undefined item"));
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 696e7b3605e2e68aee0351116c494c2958506add..7cbda3876451687aa7a55547fc06bbe86ac9d260 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -222,13 +222,13 @@ async fn dispatch_ui_action(
             }
 
             let text = format!(
-                "@{}\n{} {}:{} {} {{\n{}\n}}\n",
+                "@{}\n{{\n{}\n}}\n{} {}:{} {}\n",
                 crate::api::auth::WEB_BROWSER_AGENT,
+                exp,
                 left_id.as_str(),
                 rl,
                 rr,
-                right_id.as_str(),
-                exp
+                right_id.as_str()
             );
 
             match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index 7962342bd023b3b5061a684d84d4ab164134b111..def8b497c71567016a522648b9630d7038163e41 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -11,16 +11,21 @@ pub struct Document {
 /// A single statement in the DSL (or prose when using `parse_full`).
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum Stmt {
-    Item { title: String, body: Option<String> },
+    Item {
+        title: String,
+        body: Option<String>,
+    },
     Vote {
         item1: String,
         item2: String,
         ratio_left: i32,
         ratio_right: i32,
-        /// Required non-empty explanation (from trailing `{ ... }`).
+        /// Required non-empty explanation (from leading `{ ... }`).
         explanation: String,
     },
-    Prose { text: String },
+    Prose {
+        text: String,
+    },
 }
 
 #[derive(Debug, thiserror::Error)]
@@ -391,71 +396,46 @@ fn parse_comparison_at(s: &str, i: usize) -> Option<((i32, i32), usize)> {
     Some(((left, right), j))
 }
 
-fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
-    // item: ("~/" | "https://..." | "http://...") item_ref body?
-    // vote: same for both operands.
-    //
-    // Important: body token can be adjacent to the item name (no whitespace),
-    // e.g. "~/arrived{...}" -> "~/arrived__BLOCK_x__".
-    let s = stripped;
-    let bytes = s.as_bytes();
-    if bytes.is_empty() {
-        return Err(DslError::Parse("missing item statement".to_string()));
+fn parse_block_prefixed_statement(
+    block_token: &str,
+    tail: &str,
+    masker: &BlockMasker,
+) -> Result<Stmt, DslError> {
+    // vote: block item_ref comparison item_ref
+    let s = tail.trim_start();
+    if s.is_empty() {
+        return Err(DslError::Parse(
+            "missing vote statement after leading explanation block".to_string(),
+        ));
     }
 
     let (item1, j) =
         parse_item_name_at(s, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+    let explanation = masker.extract_body(block_token);
+    let mut i = skip_ws(s, j);
 
-    // Either we have:
-    // - immediate/whitespace block token => Item
-    // - comparison => Vote
-    // - whitespace then block token => Item
-    // - whitespace then comparison => Vote
-    let i = skip_ws(s, j);
-
-    // If next is end or a block token => Item.
     if i >= s.len() {
-        return Ok(Stmt::Item {
-            title: item1,
-            body: None,
-        });
-    }
-    if let Some((tok, end)) = parse_block_token_at(s, i) {
-        let body = masker.extract_body(&tok);
-        let tail = s[end..].trim();
-        if !tail.is_empty() {
-            return Err(DslError::Parse("extra tokens after item".to_string()));
-        }
-        return Ok(Stmt::Item {
-            title: item1,
-            body: Some(body),
-        });
+        return Err(DslError::Parse(
+            "leading `{ ... }` blocks are vote explanations; item bodies belong after item paths"
+                .to_string(),
+        ));
     }
 
-    // Otherwise parse comparison then "/item2" then REQUIRED body.
-    let ((ratio_left, ratio_right), mut k) = parse_comparison_at(s, i)
+    let ((ratio_left, ratio_right), k) = parse_comparison_at(s, i)
         .ok_or_else(|| DslError::Parse(format!("invalid comparison near: {}", &s[i..])))?;
     if ratio_left == 0 && ratio_right == 0 {
         return Err(DslError::Parse(
             "vote ratio 0:0 is invalid; use 1:1 for a tie or omit the vote".to_string(),
         ));
     }
-    k = skip_ws(s, k);
-    let (item2, mut m) = parse_item_name_at(s, k)
+    i = skip_ws(s, k);
+    let (item2, m) = parse_item_name_at(s, i)
         .ok_or_else(|| DslError::Parse("invalid rhs item name".to_string()))?;
-    m = skip_ws(s, m);
-
-    let Some((tok, end)) = parse_block_token_at(s, m) else {
-        return Err(DslError::Parse(
-            "missing vote explanation (add a trailing `{ ... }`)".to_string(),
-        ));
-    };
-    let explanation = masker.extract_body(&tok);
+    i = skip_ws(s, m);
     if explanation.trim().is_empty() {
         return Err(DslError::Parse("empty vote explanation".to_string()));
     }
-    m = end;
-    let tail = s[m..].trim();
+    let tail = s[i..].trim();
     if !tail.is_empty() {
         return Err(DslError::Parse("extra tokens after vote".to_string()));
     }
@@ -469,6 +449,35 @@ fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, Ds
     })
 }
 
+fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
+    let (item1, j) =
+        parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+    let i = skip_ws(stripped, j);
+
+    if i >= stripped.len() {
+        return Ok(Stmt::Item {
+            title: item1,
+            body: None,
+        });
+    }
+
+    if let Some((tok, end)) = parse_block_token_at(stripped, i) {
+        let body = masker.extract_body(&tok);
+        let tail = stripped[end..].trim();
+        if !tail.is_empty() {
+            return Err(DslError::Parse("extra tokens after item".to_string()));
+        }
+        return Ok(Stmt::Item {
+            title: item1,
+            body: Some(body),
+        });
+    }
+
+    Err(DslError::Parse(
+        "vote explanations must start with a `{ ... }` block before the comparison".to_string(),
+    ))
+}
+
 fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslError> {
     let stripped = masked_line.trim_start();
     if stripped.is_empty() {
@@ -477,27 +486,28 @@ fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslE
     let first = stripped.chars().next().unwrap();
     match first {
         '#' => Err(DslError::Parse("not a DS

… preview truncated; 47,409 characters omitted

download full diff A

B — 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 B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.