{"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[558dfca8] fix\n\nSide A — unified diff (full patch):\ndiff --git a/.github/workflows/release.yml b/.github/workflows/release.yml\nindex 6f3e27e491b56aaaa1ef64c547cc68921e010b3f..5ed117dddd0bc36f89e3b3788ae55e7f871312a9 100644\n--- a/.github/workflows/release.yml\n+++ b/.github/workflows/release.yml\n@@ -88,12 +88,9 @@ jobs:\n \n - uses: actions/setup-node@v4\n with:\n- node-version: 22\n+ node-version: 24\n registry-url: \"https://registry.npmjs.org\"\n \n- - name: Upgrade npm for OIDC trusted publishing support\n- run: npm install -g npm@latest\n-\n - name: Copy binaries into npm platform packages\n shell: bash\n run: |\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex f42e907775645166c60aeae2d98c5855223a71be..e3299ee353f8c62935fa82708762490a6d5c54e3 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -70,14 +70,6 @@ mod tests {\n }));\n }\n \n- #[test]\n- fn validate_ingest_document_requires_actor() {\n- let reduced = ReducerState::default();\n- let text = \"~/t/a {a}\\n~/t/b {b}\\n\";\n- let v = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();\n- assert_eq!(v.raw_text.trim(), text.trim());\n- }\n-\n #[test]\n fn validate_ingest_document_parse_error() {\n let reduced = ReducerState::default();\n@@ -108,14 +100,6 @@ mod tests {\n assert!(err.1.contains(\"undefined item\"));\n }\n \n- #[test]\n- fn validate_ingest_document_requires_tag() {\n- let reduced = ReducerState::default();\n- // tags are no longer DSL routing metadata; validation no longer requires them.\n- let text = \"~/t/a {a}\\n~/t/b {b}\\n\";\n- validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();\n- }\n-\n #[test]\n fn validate_ingest_document_accepts_quoted_thread_title() {\n let reduced = ReducerState::default();\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[595b3850] Fix star-topology ranking by using degree-based d_max (#146).\n\nA pure forward star at the default `>` ratio (2:1) produced uniform 1/3\nscores, and the alphabetical-fallback sort placed the unambiguous winner\nlast. Root cause: `compute_scores_from_edges` divided by the max sum of\npairwise-normalized weights, so every node ended up with P_ii = 0 — a\nbipartite Markov chain whose power iteration oscillated and, after the\nconfigured even iteration count, returned to the uniform initial state.\n\nSwitch the divisor to the unweighted max neighbor degree, matching the\ncanonical Rank Centrality definition in Negahban–Oh–Shah 2012 §3.1\n(arXiv:1209.1688, eq. defP and the d_max definition in §6). This gives\nevery non-saturated node a positive self-loop, makes the chain aperiodic,\nand converges the star to π_zebra = 1/2, π_alpha = π_beta = 1/4.\n\nAdd Rust regression test and a Clojure test that drives the sorterc\nbinary against four .sorter fixtures (star, inverse star, chain, cycle).\n\nCo-Authored-By: Claude Opus 4.7 (1M context) \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex 3710c9f64437f5bef3b2121905b6f3bcb7611047..38e6d09b4370e5f8cbae09c0e5760b4e7f1ef7db 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -1,4 +1,4 @@\n-use std::collections::HashMap;\n+use std::collections::{HashMap, HashSet};\n \n use crate::path_types::ItemId;\n use crate::reducer::GroupState;\n@@ -143,23 +143,35 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator 0 for every non-maximum-degree node, and for max-degree\n+ // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+ // loss). Without this, regular comparison graphs (e.g. a pure star at\n+ // ratio 2:1) produce a bipartite chain that oscillates instead of\n+ // converging — see issue #146.\n let mut out_edges: Vec> = vec![Vec::new(); n];\n- let mut out_deg: Vec = vec![0.0; n];\n+ let mut neighbors: Vec> = vec![HashSet::new(); n];\n \n for ((src, dst), w) in &normalized {\n out_edges[*src].push((*dst, *w));\n- out_deg[*src] += w;\n+ neighbors[*src].insert(*dst);\n+ neighbors[*dst].insert(*src);\n }\n \n- let mut max_out = 0.0f64;\n- for &d in &out_deg {\n- if d > max_out {\n- max_out = d;\n- }\n- }\n- if max_out <= 1e-12 {\n+ let weight_sum: Vec = out_edges\n+ .iter()\n+ .map(|es| es.iter().map(|(_, w)| *w).sum())\n+ .collect();\n+ let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+ if d_max == 0 {\n return vec![1.0 / n as f64; n];\n }\n+ let d_max_f = d_max as f64;\n \n let mut scores = vec![1.0 / n as f64; n];\n let mut next = vec![0.0f64; n];\n@@ -167,14 +179,14 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator` ratio (2:1).\n+ /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+ /// chain was bipartite; power iteration oscillated and returned the\n+ /// uniform initial distribution after an even number of steps. Using the\n+ /// paper's degree-based d_max gives every node a positive self-loop and\n+ /// the chain converges to the correct stationary distribution.\n+ #[test]\n+ fn star_topology_winner_at_top_via_subset() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+ let mut items: Vec<(usize, String)> = g\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, it)| (i, it.as_str().to_string()))\n+ .collect();\n+ items.sort_by(|a, b| a.1.cmp(&b.1));\n+ let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+ let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+ for r in &ranked {\n+ eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+ }\n+ assert_eq!(\n+ ranked[0].item.as_str(),\n+ \"https://slug.social/zebra\",\n+ \"zebra won both votes and should rank #1\"\n+ );\n+ }\n+\n #[test]\n fn group_ranking_cache_dirty_flow() {\n let mut g = mk_group();\ndiff --git a/test/fixtures/ranking/chain.sorter b/test/fixtures/ranking/chain.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..912a96bdc08f631be27f3c9afc7e05004a2457c0\n--- /dev/null\n+++ b/test/fixtures/ranking/chain.sorter\n@@ -0,0 +1,10 @@\n+#t3\n+\n+~/t3/a { head of chain }\n+~/t3/b { middle }\n+~/t3/c { tail }\n+\n+{ a > b }\n+~/t3/a > ~/t3/b\n+{ b > c }\n+~/t3/b > ~/t3/c\ndiff --git a/test/fixtures/ranking/cycle.sorter b/test/fixtures/ranking/cycle.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..771731ae176e5d77e00ab89767ff2d7465bb7c6b\n--- /dev/null\n+++ b/test/fixtures/ranking/cycle.sorter\n@@ -0,0 +1,12 @@\n+#t4\n+\n+~/t4/a { node a }\n+~/t4/b { node b }\n+~/t4/c { node c }\n+\n+{ a > b }\n+~/t4/a > ~/t4/b\n+{ b > c }\n+~/t4/b > ~/t4/c\n+{ c > a }\n+~/t4/c > ~/t4/a\ndiff --git a/test/fixtures/ranking/star.sorter b/test/fixtures/ranking/star.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..135c9f9097d57b73c7ab18fac737ed3598d76fbe\n--- /dev/null\n+++ b/test/fixtures/ranking/star.sorter\n@@ -0,0 +1,11 @@\n+#repro\n+\n+~/repro/zebra { winner — beats both others }\n+~/repro/alpha { loser — alphabetically first }\n+~/repro/beta { loser — alphabetically middle }\n+\n+{ zebra beats alpha }\n+~/repro/zebra > ~/repro/alpha\n+\n+{ zebra beats beta }\n+~/repro/zebra > ~/repro/beta\ndiff --git a/test/fixtures/ranking/star_inverse.sorter b/test/fixtures/ranking/star_inverse.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..dab842fa924e5124865ee216b9565f6c7471c812\n--- /dev/null\n+++ b/test/fixtures/ranking/star_inverse.sorter\n@@ -0,0 +1,11 @@\n+#t2\n+\n+~/t2/win { source of incoming edges (loses both) }\n+~/t2/loss-a { winner }\n+~/t2/loss-b { winner }\n+\n+{ loss-a beats win }\n+~/t2/loss-a > ~/t2/win\n+\n+{ loss-b beats win }\n+~/t2/loss-b > ~/t2/win\ndiff --git a/test/ranking.clj b/test/ranking.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e1358783a84a264ee633bd79151ba29750b717cf\n--- /dev/null\n+++ b/test/ranking.clj\n@@ -0,0 +1,74 @@\n+(ns test.ranking\n+ \"Drives sorterc on .sorter fixtures and asserts ranking properties.\n+\n+ Regression coverage for issue #146 — pure forward star at default ratio\n+ (2:1 for `>`) used to produce tied uniform scores because the random walk\n+ on the normalized edge weights was bipartite. Fixed by switching to the\n+ degree-based d_max from Negahban–Oh–Shah rank centrality (§3.1).\"\n+ (:require [clojure.test :refer [deftest is testing]]\n+ [babashka.process :as p]\n+ [cheshire.core :as json]\n+ [clojure.java.io :as io]))\n+\n+(def sorterc-bin\n+ \"Path to the locally-built sorterc binary. Builds on demand if missing.\"\n+ (let [dbg \"target/debug/sorterc\"\n+ release \"target/release/sorterc\"]\n+ (cond\n+ (.exists (io/file release)) release\n+ (.exists (io/file dbg)) dbg\n+ :else\n+ (do (println \"building sorterc…\")\n+ (let [r (p/shell {:out :string :err :string :continue true}\n+ \"cargo build -p sorterc\")]\n+ (when-not (zero? (:exit r))\n+ (throw (ex-info \"cargo build -p sorterc failed\"\n+ {:stderr (:err r)}))))\n+ dbg))))\n+\n+(defn compile-sorter [fixture-path]\n+ (let [{:keys [out exit]} (p/shell {:out :string :err :string :continue true}\n+ sorterc-bin \"compile\" fixture-path)]\n+ (when-not (zero? exit)\n+ (throw (ex-info \"sorterc exit nonzero\" {:fixture fixture-path :out out})))\n+ (json/parse-string out true)))\n+\n+(defn first-component-ranking [result]\n+ (-> result :rankings first :components first :ranking))\n+\n+(defn item-leaf [item]\n+ (last (clojure.string/split item #\"/\")))\n+\n+(deftest chain-ranks-head-first\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/chain.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)]\n+ (is (= [\"a\" \"b\" \"c\"] names)\n+ \"chain a>b>c should rank a, b, c in order\")\n+ (is (apply > (map :score ranking))\n+ \"scores should attenuate strictly down the chain\")))\n+\n+(deftest inverse-star-puts-winners-on-top\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/star_inverse.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)]\n+ (is (= \"win\" (last names))\n+ \"the item that lost to both others should be ranked last\")))\n+\n+(deftest cycle-produces-uniform-scores\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/cycle.sorter\"))\n+ scores (map :score ranking)]\n+ (is (every? #(< (Math/abs (- % 1/3)) 1e-3) scores)\n+ \"a perfectly symmetric 3-cycle should give every node ~1/3\")))\n+\n+(deftest star-topology-winner-at-top\n+ ;; Issue #146 regression: source-only star at default `>` ratio (2:1).\n+ ;; Pre-fix produced uniform 1/3 scores; alphabetical fallback put the\n+ ;; unambiguous winner at the bottom. Post-fix the chain is aperiodic and\n+ ;; converges to π_zebra = 1/2, π_alpha = π_beta = 1/4.\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/star.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)\n+ by-name (into {} (map (juxt (comp item-leaf :item) :score) ranking))]\n+ (is (= \"zebra\" (first names))\n+ \"zebra won both votes and should rank #1\")\n+ (is (< (Math/abs (- (by-name \"zebra\") 0.5)) 1e-3))\n+ (is (< (Math/abs (- (by-name \"alpha\") 0.25)) 1e-3))\n+ (is (< (Math/abs (- (by-name \"beta\") 0.25)) 1e-3))))\n","role":"user"}],"model":"openai/gpt-chat-latest"}