{"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[7964b28f] fix\n\nSide A — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 8c43fb75c472b102e6e1d3b837dce3355be898f2..e55d87f32ab32064686431c7082ef8c9ca872d63 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -923,6 +923,15 @@ version = \"0.2.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391\"\n \n+[[package]]\n+name = \"ppv-lite86\"\n+version = \"0.2.21\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9\"\n+dependencies = [\n+ \"zerocopy\",\n+]\n+\n [[package]]\n name = \"prettyplease\"\n version = \"0.2.37\"\n@@ -980,6 +989,36 @@ version = \"6.0.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf\"\n \n+[[package]]\n+name = \"rand\"\n+version = \"0.8.6\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a\"\n+dependencies = [\n+ \"libc\",\n+ \"rand_chacha\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_chacha\"\n+version = \"0.3.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88\"\n+dependencies = [\n+ \"ppv-lite86\",\n+ \"rand_core\",\n+]\n+\n+[[package]]\n+name = \"rand_core\"\n+version = \"0.6.4\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c\"\n+dependencies = [\n+ \"getrandom 0.2.17\",\n+]\n+\n [[package]]\n name = \"regex-automata\"\n version = \"0.4.14\"\n@@ -1270,6 +1309,7 @@ dependencies = [\n \"dotenvy\",\n \"futures-util\",\n \"maud\",\n+ \"rand\",\n \"reqwest\",\n \"serde\",\n \"serde_json\",\n@@ -1280,6 +1320,7 @@ dependencies = [\n \"tower-http 0.5.2\",\n \"tracing\",\n \"tracing-subscriber\",\n+ \"urlencoding\",\n ]\n \n [[package]]\n@@ -1656,6 +1697,12 @@ dependencies = [\n \"serde\",\n ]\n \n+[[package]]\n+name = \"urlencoding\"\n+version = \"2.1.3\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da\"\n+\n [[package]]\n name = \"utf8_iter\"\n version = \"1.0.4\"\n@@ -2052,6 +2099,26 @@ dependencies = [\n \"synstructure\",\n ]\n \n+[[package]]\n+name = \"zerocopy\"\n+version = \"0.8.50\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1\"\n+dependencies = [\n+ \"zerocopy-derive\",\n+]\n+\n+[[package]]\n+name = \"zerocopy-derive\"\n+version = \"0.8.50\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639\"\n+dependencies = [\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn\",\n+]\n+\n [[package]]\n name = \"zerofrom\"\n version = \"0.1.8\"\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex c940acb687fb141d21760a3d6656172013cf6f41..6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -20,6 +20,8 @@ reqwest = { version = \"0.12\", features = [\"json\"] }\n dotenvy = \"0.15\"\n async-stream = \"0.3\"\n futures-util = { version = \"0.3\", default-features = false, features = [\"std\"] }\n+rand = \"0.8\"\n+urlencoding = \"2\"\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex e649a7d192feade465e19ce6187a829f6ec74372..06001212820101e0cc953d3687dea64f85e60787 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -47,6 +47,7 @@ pub async fn post_ui_html(\n ratio_left,\n ratio_right,\n scope,\n+ next,\n } => {\n let parent = parent_from_scope(&scope);\n if let Err(e) = state\n@@ -56,9 +57,19 @@ pub async fn post_ui_html(\n return ui_js_warn(&e).into_response();\n }\n let tree = state.tree.read().await;\n+ if !next.trim().is_empty() {\n+ drop(tree);\n+ return JsBuilder::new()\n+ .raw(&format!(\n+ \"window.location.href={};\",\n+ js_string_literal(next.trim())\n+ ))\n+ .into_response();\n+ }\n let empty = crate::reducer::NodeState::default();\n let node = tree.get(&parent).unwrap_or(&empty);\n let panel = ranking_panel(&parent, node, &tree);\n+ drop(tree);\n JsBuilder::new()\n .morph_selector(\"#ranking-panel\", panel)\n .into_response()\n@@ -126,6 +137,7 @@ mod tests {\n ratio_left: 3,\n ratio_right: 1,\n scope: String::new(),\n+ next: String::new(),\n }\n );\n }\ndiff --git a/server/src/form_template.rs b/server/src/form_template.rs\nindex b9bc3982a125e94e67c99175ea9055979541abba..bd4a7195a6eaabfed55333bab5640708ea108b81 100644\n--- a/server/src/form_template.rs\n+++ b/server/src/form_template.rs\n@@ -7,19 +7,30 @@ 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+/// Recursively walk the JSON AST and replace form holes with submitted values.\n+///\n+/// - `{\"$form\": \"key\"}` → string (empty if missing)\n+/// - `{\"$form:i32\": \"key\"}` → JSON number (0 if missing or unparseable)\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+ if let Some((hole_key, Value::String(field_name))) = map.iter().next() {\n+ if let Some(form_type) = hole_key.strip_prefix(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = match form_type {\n+ \"\" => Value::String(submitted.to_string()),\n+ \":i32\" => {\n+ let n: i32 = submitted.trim().parse().unwrap_or(0);\n+ Value::Number(n.into())\n+ }\n+ _ => Value::String(submitted.to_string()),\n+ };\n+ return;\n+ }\n }\n }\n for v in map.values_mut() {\n@@ -62,6 +73,45 @@ mod tests {\n text: String,\n }\n \n+ #[test]\n+ fn i32_holes_become_numbers() {\n+ let json = r#\"{\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"ratio_left\".into(), \"75\".into());\n+ form.insert(\"ratio_right\".into(), \"25\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"ratio_left\"], 75);\n+ assert_eq!(v[\"ratio_right\"], 25);\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Ratios {\n+ ratio_left: i32,\n+ ratio_right: i32,\n+ }\n+ let r: Ratios = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ r,\n+ Ratios {\n+ ratio_left: 75,\n+ ratio_right: 25,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn i32_hole_missing_or_bad_defaults_to_zero() {\n+ let json = r#\"{\"n\": {\"$form:i32\": \"missing\"}}\"#;\n+ let v = fill_template_from_form(json, &HashMap::new()).unwrap();\n+ assert_eq!(v[\"n\"], 0);\n+ let mut form = HashMap::new();\n+ form.insert(\"missing\".into(), \"nope\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"n\"], 0);\n+ }\n+\n #[test]\n fn holes_become_strings() {\n let json = r#\"{\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 3bf9fc7e92e50beed24e2c25106a77421038c90b..61cdbc094819ddedb755572c59456ec0d6617619 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -20,6 +20,8 @@ use crate::{\n ui_action::UI_RPC_FIELD,\n };\n \n+pub mod vote;\n+\n const SORTER_CSS: &str = include_str!(\"../../static/sorter.css\");\n const SORTER_UI_JS: &str = include_str!(\"../../static/sorter_ui.js\");\n \n@@ -131,7 +133,7 @@ fn layout(title: &str, body: Markup, views: u64) -> Markup {\n }\n }\n \n-fn item_href(id: &ItemId) -> String {\n+pub(crate) fn item_href(id: &ItemId) -> String {\n id.browse_href()\n }\n \n@@ -309,11 +311,23 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n let empty_node = NodeState::default();\n let node = tree.get(&item).unwrap_or(&empty_node);\n \n+ let child_count = node.children.len();\n+ let vote_link = if child_count >= 2 {\n+ Some(vote::vote_href(&item))\n+ } else {\n+ None\n+ };\n+\n let body = html! {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n (entity_section(&item, node, false))\n+ @if let Some(href) = vote_link {\n+ p class=\"vote-cta\" {\n+ a class=\"btn-primary\" href=(href) data-testid=\"vote-children\" { \"Vote on children\" }\n+ }\n+ }\n (ranking_panel(&item, node, &tree))\n };\n layout(\"sorter2\", body, views)\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex da5f3ebecec1794b05a2a69cc78379551b2ad769..7f7e28c8ac3758de87f1f8e073b24be4132d38da 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -4,6 +4,7 @@ pub mod events;\n pub mod fetch;\n pub mod form_template;\n pub mod html;\n+pub mod pair;\n pub mod parser;\n pub mod path_types;\n pub mod ranking;\n@@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router {\n .route(\"/static/:filename\", get(crate::html::serve_static))\n .route(\"/~/*item_path\", get(crate::html::browse))\n .route(\"/\", get(crate::html::home))\n+ .route(\"/vote\", get(crate::html::vote::vote_page))\n .route(\"/ui\", post(crate::api::ui_html::post_ui_html))\n .with_state(state)\n .layer(TraceLayer::new_for_http())\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex 2047713762c932ac9bc325fe15624f2aecb3364d..53581e1362bfcc5dfb4ae3069c41ea6f7be41437 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -31,6 +31,9 @@ pub enum HtmlUiAction {\n /// Parent node [`ItemId`] string; empty = tree root.\n #[serde(default)]\n scope: String,\n+ /// After vote, navigate here (vote compare page).\n+ #[serde(default)]\n+ next: String,\n },\n /// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.\n ParseQuery {\n@@ -70,6 +73,36 @@ pub fn parse_html_ui_from_form(\n mod tests {\n use super::*;\n \n+ #[test]\n+ fn record_vote_round_trip_with_typed_ratio_holes() {\n+ let template = serde_json::json!({\n+ \"action\": \"record_vote\",\n+ \"a\": \"x\",\n+ \"b\": \"y\",\n+ \"ratio_left\": {\"$form:i32\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form:i32\": \"ratio_right\"},\n+ \"scope\": \"parent\",\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(\"ratio_left\".into(), \"60\".into());\n+ form.insert(\"ratio_right\".into(), \"40\".into());\n+ assert_eq!(\n+ parse_html_ui_from_form(&form).unwrap(),\n+ HtmlUiAction::RecordVote {\n+ a: \"x\".into(),\n+ b: \"y\".into(),\n+ ratio_left: 60,\n+ ratio_right: 40,\n+ scope: \"parent\".into(),\n+ next: String::new(),\n+ }\n+ );\n+ }\n+\n #[test]\n fn record_vote_round_trip_with_form_holes() {\n let template = serde_json::json!({\n@@ -96,6 +129,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: \"amitheasshole\".into(),\n+ next: String::new(),\n }\n );\n }\n@@ -122,6 +156,7 @@ mod tests {\n ratio_left: 2,\n ratio_right: 1,\n scope: String::new(),\n+ next: String::new(),\n }\n );\n }\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 021918f8e778b84e4b3eac8559fab7d223a3d490..3c2cec5cb5094013387147c621d75bd341bc06cc 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -224,4 +224,148 @@ code {\n \n h1 {\n margin: 0;\n+}\n+\n+.vote-cta {\n+ margin: 0.75rem 0;\n+}\n+\n+.vote-compare-shell {\n+ max-width: 960px;\n+}\n+\n+.vote-compare-scope {\n+ margin: 0.5rem 0 1rem;\n+}\n+\n+.vote-compare-pair {\n+ display: grid;\n+ grid-template-columns: 1fr auto 1fr;\n+ gap: 1rem;\n+ align-items: start;\n+ margin: 1rem 0;\n+}\n+\n+.vote-compare-vs {\n+ align-self: center;\n+ font-size: 1.25rem;\n+ color: var(--muted);\n+ font-weight: 600;\n+}\n+\n+.vote-compare-side {\n+ background: var(--panel);\n+ border: 1px solid var(--border);\n+ border-radius: 8px;\n+ padding: 1rem;\n+ min-height: 120px;\n+}\n+\n+.vote-compare-item {\n+ color: var(--accent);\n+ text-decoration: none;\n+ display: block;\n+}\n+\n+.vote-compare-item:hover {\n+ text-decoration: underline;\n+}\n+\n+.vote-compare-figure {\n+ margin: 0.75rem 0 0;\n+}\n+\n+.vote-compare-image {\n+ display: block;\n+ max-width: 100%;\n+ height: auto;\n+ border-radius: 6px;\n+ border: 1px solid var(--border);\n+}\n+\n+.vote-compare-item-body {\n+ margin-top: 0.75rem;\n+ font-size: 0.9rem;\n+}\n+\n+.vote-compare-nav {\n+ display: flex;\n+ justify-content: space-between;\n+ align-items: center;\n+ gap: 1rem;\n+ margin: 1rem 0;\n+}\n+\n+.vote-compare-next {\n+ color: var(--accent);\n+ text-decoration: none;\n+}\n+\n+.vote-compare-next.is-disabled {\n+ color: var(--muted);\n+}\n+\n+.vote-compare-back {\n+ text-decoration: none;\n+}\n+\n+.vote-compare-back:hover {\n+ text-decoration: underline;\n+}\n+\n+.vote-compare-slider-label {\n+ display: grid;\n+ grid-template-columns: 1fr auto 1fr;\n+ gap: 0.75rem;\n+ align-items: center;\n+ margin: 1.25rem 0;\n+}\n+\n+.vote-compare-slider-label input[type=\"range\"] {\n+ width: 100%;\n+ min-width: 160px;\n+}\n+\n+#vote-slider-left-label {\n+ text-align: right;\n+}\n+\n+#vote-slider-right-label {\n+ text-align: left;\n+}\n+\n+.vote-edge-history-title {\n+ margin: 1.5rem 0 0.5rem;\n+ font-size: 1rem;\n+}\n+\n+.vote-edge-history {\n+ list-style: none;\n+ padding: 0;\n+ margin: 0;\n+}\n+\n+.vote-edge-history-row {\n+ margin-bottom: 0.75rem;\n+}\n+\n+.vote-edge-meta {\n+ font-size: 0.875rem;\n+ margin-bottom: 0.25rem;\n+}\n+\n+.ratio-bar {\n+ display: flex;\n+ height: 8px;\n+ border-radius: 4px;\n+ overflow: hidden;\n+ background: var(--border);\n+}\n+\n+.ratio-left {\n+ background: var(--accent);\n+}\n+\n+.ratio-right {\n+ background: var(--muted);\n }\n\\ No newline at end of file\ndiff --git a/server/static/sorter_ui.js b/server/static/sorter_ui.js\nindex 62c9158ecb3c2140b83efd2b27ea706a9e0ae127..c8d7c3ef2413399ced7898a7edb3e7a2c8e16163 100644\n--- a/server/static/sorter_ui.js\n+++ b/server/static/sorter_ui.js\n@@ -81,7 +81,22 @@\n });\n }\n \n+ function initVoteSlider() {\n+ var slider = document.getElementById('vote-preference-slider');\n+ if (!slider) return;\n+ var leftInput = document.getElementById('vote-ratio-left');\n+ var rightInput = document.getElementById('vote-ratio-right');\n+ function update() {\n+ var v = parseInt(slider.value, 10);\n+ if (leftInput) leftInput.value = String(v);\n+ if (rightInput) rightInput.value = String(100 - v);\n+ }\n+ slider.addEventListener('input', update);\n+ update();\n+ }\n+\n function initSorterUi() {\n+ initVoteSlider();\n document.addEventListener('submit', async function (e) {\n var f = e.target;\n if (!f || f.tagName !== 'FORM') return;\n@@ -89,10 +104,11 @@\n if (f.getAttribute('data-navigate') === 'full') return;\n e.preventDefault();\n await postUiForm(f);\n- if (f.id === 'vote-form') {\n+ if (f.id === 'vote-form' || f.id === 'vote-compare-form') {\n f.reset();\n- var firstField = f.querySelector('input[type=\"text\"]');\n- if (firstField) firstField.focus();\n+ var slider = f.querySelector('#vote-preference-slider');\n+ if (slider) slider.value = '50';\n+ initVoteSlider();\n }\n });\n }\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[047b82bd] Remove redundant zero-ratio guard from reducer.\n\nZeros are already rejected at the DSL parser and browser handler;\nthe guard in apply_vote was dead code. The negative clamping stays\nsince add_edge_weight already skips weight-0 edges correctly.\n\nCo-Authored-By: Claude Sonnet 4.6 \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 0e36979abe0f051493038ff7e652efc7f7a0ac80..efbf7f67ff24c7a2989102fe62879b2794a27c5f 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -112,11 +112,6 @@ impl GroupState {\n if vote.ratio_right < 0 {\n vote.ratio_right = 0;\n }\n- if vote.ratio_left == 0 || vote.ratio_right == 0 {\n- // Zero on either side produces no valid edge; drop before registering items or pair.\n- return;\n- }\n-\n let a_idx = self.ensure_item(&vote.a);\n let b_idx = self.ensure_item(&vote.b);\n \ndiff --git a/server/tests/basic.rs b/server/tests/basic.rs\nindex 08159f4a7f0850fd165817a4a1af4f31ced2ad76..1748769ccf7196b2d81cf556d5368c7e723f6a4e 100644\n--- a/server/tests/basic.rs\n+++ b/server/tests/basic.rs\n@@ -533,7 +533,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {\n #[test]\n fn reducer_negative_ratio_clamped_to_zero() {\n let _state = ReducerState::default();\n- // GroupState::apply_vote clamps negatives to 0; when either side is 0 the vote is dropped.\n+ // apply_vote clamps negatives to 0; add_edge_weight skips zero-weight edges.\n let mut group = GroupState::new();\n group.apply_vote(slugsocial_server::reducer::VoteData {\n ts: 1,\n@@ -546,10 +546,9 @@ fn reducer_negative_ratio_clamped_to_zero() {\n delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n thread_tag: \"t\".to_string(),\n });\n- // Nothing registered: zero-clamped vote is dropped before ensure_item.\n- assert!(group.idx_to_item.is_empty());\n+ // Items and pair are registered; edges are absent because weight 0 is skipped.\n+ assert_eq!(group.idx_to_item.len(), 2);\n assert!(group.edges.is_empty());\n- assert!(group.voted_pairs.is_empty());\n }\n \n \n","role":"user"}],"model":"~x-ai/grok-latest"}