constitution · epochs · watch · epoch 3

comparison

c_8dc1a8119370 (tommy-mor) vs c_48aeaf9b52c3 (tommy-mor)

download prompt · raw event · cmp_928baa415738ab

council reasoning

~anthropic/claude-sonnet-latest · winner B · 80:20 · permalink

Side B fixes a real algorithmic correctness bug (bipartite Markov chain oscillation in rank centrality) with a principled derivation tied to the cited paper, plus targeted regression tests in both Rust and Clojure across multiple topologies. Side A mostly deletes a large, over-engineered autocomplete parser and its test suite in favor of a simpler paste-and-go textarea, which is a reasonable simplification but is primarily churn/removal rather than a substantive lasting improvement, and it also strips out a Playwright race-condition test without an equivalent replacement guarantee.

~x-ai/grok-latest · winner B · 2:3 · permalink

B fixes a core ranking-math bug (wrong d_max made star topologies bipartite and yield uniform scores) to match Negahban–Oh–Shah, with tight Rust and fixture-driven Clojure regressions—directly correcting the product’s central output. A is a worthwhile simplification (deleting the brittle keystroke graph/parser_action/race harness for paste-and-go), but it mainly retires secondary UI machinery rather than fixing lasting algorithmic correctness.

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

Side B fixes a correctness bug in the core Rank Centrality implementation by replacing the normalization divisor with the canonical degree-based d_max, preventing oscillation in star-topology graphs and producing the correct stationary distribution. It also adds focused Rust and end-to-end fixture regressions for star, inverse star, chain, and cycle cases, whereas Side A primarily removes a complex autocomplete/parser system in favor of a simpler paste-and-go UI and deletes substantial functionality rather than improving the underlying ranking logic.

sides

A — c_8dc1a8119370 (tommy-mor)

message

[529cc941] Replace autocomplete parser with paste-and-go navigate.

The keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope.

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

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
 - **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.
 - **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).
 - **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"chromium\" \"--with-deps\"]))"`. The browser binary is cached under `~/.cache/ms-playwright`.
 
 ### Commands (see also `TEST.sh`)
 
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -8,7 +8,7 @@ use std::collections::HashMap;
 use crate::{
     html::{js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
-    parser_render::parser_panel_morph,
+    parser_render::navigate_panel,
     state::AppState,
     ui_action::{parse_html_ui_from_form, HtmlUiAction},
 };
@@ -57,18 +57,23 @@ pub async fn post_ui_html(
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
         }
-        HtmlUiAction::ParseQuery { query } => {
-            let action = parse_reddit_url(&query);
-            let panel = parser_panel_morph(&query, &action);
-            let mut js = JsBuilder::new().morph_selector("#parser-panel", panel);
-            if let Some(comp) = action.primary_completion() {
-                js = js.raw(&format!(
-                    "var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}",
-                    js_string_literal(comp)
-                ));
+        HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
+            Ok(subreddit) => {
+                let dest = format!("/?sub={subreddit}");
+                JsBuilder::new()
+                    .raw(&format!(
+                        "window.location.href={};",
+                        js_string_literal(&dest)
+                    ))
+                    .into_response()
             }
-            js.into_response()
-        }
+            Err(message) => {
+                let panel = navigate_panel(&query, Some(&message));
+                JsBuilder::new()
+                    .morph_selector("#parser-panel", panel)
+                    .into_response()
+            }
+        },
     }
 }
 
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -11,8 +11,7 @@ use serde::Deserialize;
 
 use crate::{
     form_template::template_json_compact,
-    parser_action::ParserAction,
-    parser_render::parser_panel,
+    parser_render::navigate_panel,
     ranking::{top_bottom, RankedItem},
     reducer::GroupState,
     state::{normalize_scope, AppState},
@@ -336,10 +335,9 @@ pub async fn home(
     let empty = GroupState::new();
     let group = groups.get(&scope).unwrap_or(&empty);
 
-    let empty_action = ParserAction::suggest(String::new(), None);
     let body = html! {
         h1 { "sorter2" }
-        (parser_panel("", &empty_action))
+        (navigate_panel("", None))
         (vote_panel(&scope))
         (ranking_panel(&scope, group))
     };
diff --git a/server/src/lib.rs b/server/src/lib.rs
index fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,6 @@ pub mod events;
 pub mod form_template;
 pub mod html;
 pub mod parser;
-pub mod parser_action;
 pub mod parser_render;
 pub mod path_types;
 pub mod ranking;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,1811 +1,87 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
+//! Extract a subreddit name from a pasted Reddit URL or path.
 
-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
-    /// Matches exact literal string
-    Literal(&'static str),
-    
-    /// Matches any prefix of a string and suggests the full string
-    /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
-    PrefixOf(&'static str),
-    
-    /// Captures a variable segment (e.g., subreddit name, username)
-    Variable(&'static str),
-    
-    /// Matches any string (wildcard)
-    Any,
-}
-
-impl EdgePattern {
-    /// Try to match this pattern against input, return (consumed_chars, captured_value)
-    fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
-        match self {
-            EdgePattern::Literal(lit) => {
-                if input.starts_with(lit) {
-                    Some((lit.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::PrefixOf(target) => {
-                // Check if input is a prefix of target
-                if target.starts_with(input) && !input.is_empty() {
-                    // It's a valid prefix
-                    Some((input.len(), None))
-                } else if input.starts_with(target) {
-                    // Full match
-                    Some((target.len(), None))
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Variable(var_name) => {
-                // Consume until next '/' or end of string
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    let captured = input[..end].to_string();
-                    // Validate based on variable type
-                    if is_valid_variable(var_name, &captured) {
-                        Some((end, Some(captured)))
-                    } else {
-                        None
-                    }
-                } else {
-                    None
-                }
-            }
-            EdgePattern::Any => {
-                // Match everything until next '/' or end
-                let end = input.find('/').unwrap_or(input.len());
-                if end > 0 {
-                    Some((end, Some(input[..end].to_string())))
-                } else {
-                    None
-                }
-            }
-        }
-    }
-    
-    /// Get the completion suggestion for this pattern
-    fn completion(&self, partial: &str) -> Option<String> {
-        match self {
-            EdgePattern::PrefixOf(target) => {
-                if target.starts_with(partial) && partial != *target {
-                    Some(target.to_string())
-                } else {
-                    None
-                }
-            }
-            _ => None,
-        }
+pub fn parse_reddit_url(query: &str) -> Result<String, String> {
+    let q = query.trim();
+    if q.is_empty() {
+        return Err("Paste a Reddit URL or r/subreddit path".into());
     }
-}
 
-/// Edge in the graph
-pub struct Edge {
-    pattern: EdgePattern,
-    target: NodeId,
-    /// Optional description for autocomplete
-    description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync>;
-
-/// Node in the graph
-pub struct Node {
-    #[allow(dead_code)]
-    id: NodeId,
-    edges: Vec<Edge>,
-    handler: Option<Handler>,
-}
-
-/// The composable parser graph (immutable after `build`).
-pub struct Graph {
-    nodes: HashMap<NodeId, Node>,
-    root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
-    nodes: HashMap<NodeId, Node>,
-    current_node: Option<NodeId>,
-    root: NodeId,
-}
-
-impl GraphBuilder {
-    pub fn new() -> Self {
-        let mut nodes = HashMap::new();
-        nodes.insert(
-            "root",
-            Node {
-                id: "root",
-                edges: Vec::new(),
-                handler: None,
-            },
-        );
-
-        GraphBuilder {
-            nodes,
-            current_node: Some("root"),
-            root: "root",
-        }
+    if let Some(sub) = subreddit_after_prefix(q, "r/") {
+        return Ok(sub);
     }
 
-    /// Select a node to add edges to
-    pub fn at(mut self, node_id: NodeId) -> Self {
-        self.nodes.entry(node_id).or_insert_with(|| Node {
-            id: node_id,
-            edges: Vec::new(),
-            handler: None,
-        });
-        self.current_node = Some(node_id);
-        self
+    if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
+        return Ok(sub);
     }
-    
-    /// Add an edge from the current node
-    pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
-        self.edge_with_desc(pattern, target, None)
-    }
-    
-    /// Add an edge with description
-    pub fn edge_with_desc(
-        mut self,
-        pattern: EdgePattern,
-        target: NodeId,
-        desc: Option<&'static str>,
-    ) -> Self {
-        let current = self.current_node.expect("No current node selected");
-
-        self.nodes.entry(target).or_insert_with(|| Node {
-            id: target,
-            edges: Vec::new(),
-            handler: None,
-        });
-
-        if let Some(node) = self.nodes.get_mut(current) {
-            node.edges.push(Edge {
-                pattern,
-                target,
-                description: desc,
-            });
-        }
 
-        self
-    }
-
-    /// Set handler for current node
-    pub fn handler<F>(mut self, handler: F) -> Self
-    where
-        F: Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync + 'static,
-    {
-        let current = self.current_node.expect("No current node selected");
-        if let Some(node) = self.nodes.get_mut(current) {
-            node.handler = Some(Box::new(handler));
-        }
-        self
-    }
-    
-    /// Build the final graph
-    pub fn build(self) -> Graph {
-        Graph {
-            nodes: self.nodes,
-            root: self.root,
-        }
-    }
+    Err("Could not find a subreddit in that URL".into())
 }
 
-// --- Parser Implementation ---
-
-impl Graph {
-    pub fn parse(&self, input: &str) -> ParserAction {
-        let normalized = input.trim().to_lowercase();
-        let mut state = ParserState {
-            input: &normalized,
-            cursor: 0,
-            current_node_id: self.root,
-            context: HashMap::new(),
-            original_query: input.to_string(),
-            current_prefix: String::new(),
-        };
-        
-        self.parse_recursive(&mut state)
-    }
-    
-    fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {
-        let node = self
-            .nodes
-            .get(state.current_node_id)
-            .expect("Node not found in graph");
-
-        // If we've consumed all input, check for handler or suggestions
-        if state.cursor >= state.input.len() {
-            if let Some(handler) = &node.han

… preview truncated; 92,533 characters omitted

download full diff A

B — c_48aeaf9b52c3 (tommy-mor)

message

[595b3850] Fix star-topology ranking by using degree-based d_max (#146).

A pure forward star at the default `>` ratio (2:1) produced uniform 1/3
scores, and the alphabetical-fallback sort placed the unambiguous winner
last. Root cause: `compute_scores_from_edges` divided by the max sum of
pairwise-normalized weights, so every node ended up with P_ii = 0 — a
bipartite Markov chain whose power iteration oscillated and, after the
configured even iteration count, returned to the uniform initial state.

Switch the divisor to the unweighted max neighbor degree, matching the
canonical Rank Centrality definition in Negahban–Oh–Shah 2012 §3.1
(arXiv:1209.1688, eq. defP and the d_max definition in §6). This gives
every non-saturated node a positive self-loop, makes the chain aperiodic,
and converges the star to π_zebra = 1/2, π_alpha = π_beta = 1/4.

Add Rust regression test and a Clojure test that drives the sorterc
binary against four .sorter fixtures (star, inverse star, chain, cycle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff preview

diff --git a/server/src/ranking.rs b/server/src/ranking.rs
index 3710c9f64437f5bef3b2121905b6f3bcb7611047..38e6d09b4370e5f8cbae09c0e5760b4e7f1ef7db 100644
--- a/server/src/ranking.rs
+++ b/server/src/ranking.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 use crate::path_types::ItemId;
 use crate::reducer::GroupState;
@@ -143,23 +143,35 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator<Item = ((usize, usiz
         }
     }
 
+    // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):
+    //   P_ij = (1/d_max) * A_ij           for i ≠ j compared
+    //   P_ii = 1 - (1/d_max) * Σ_k A_ik
+    // where d_i is the *degree* (number of distinct neighbors compared) and
+    // d_max = max_i d_i. Using the unweighted degree — not the sum of
+    // pairwise-normalized weights — is what guarantees aperiodicity: it
+    // forces P_ii > 0 for every non-maximum-degree node, and for max-degree
+    // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous
+    // loss). Without this, regular comparison graphs (e.g. a pure star at
+    // ratio 2:1) produce a bipartite chain that oscillates instead of
+    // converging — see issue #146.
     let mut out_edges: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
-    let mut out_deg: Vec<f64> = vec![0.0; n];
+    let mut neighbors: Vec<HashSet<usize>> = vec![HashSet::new(); n];
 
     for ((src, dst), w) in &normalized {
         out_edges[*src].push((*dst, *w));
-        out_deg[*src] += w;
+        neighbors[*src].insert(*dst);
+        neighbors[*dst].insert(*src);
     }
 
-    let mut max_out = 0.0f64;
-    for &d in &out_deg {
-        if d > max_out {
-            max_out = d;
-        }
-    }
-    if max_out <= 1e-12 {
+    let weight_sum: Vec<f64> = out_edges
+        .iter()
+        .map(|es| es.iter().map(|(_, w)| *w).sum())
+        .collect();
+    let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);
+    if d_max == 0 {
         return vec![1.0 / n as f64; n];
     }
+    let d_max_f = d_max as f64;
 
     let mut scores = vec![1.0 / n as f64; n];
     let mut next = vec![0.0f64; n];
@@ -167,14 +179,14 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator<Item = ((usize, usiz
     for _ in 0..max_iters {
         next.fill(0.0);
         for i in 0..n {
-            let stay_prob = (max_out - out_deg[i]) / max_out;
+            let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;
             next[i] += scores[i] * stay_prob;
 
             if out_edges[i].is_empty() {
                 continue;
             }
             for &(dst, w) in &out_edges[i] {
-                next[dst] += scores[i] * (w / max_out);
+                next[dst] += scores[i] * (w / d_max_f);
             }
         }
 
@@ -270,6 +282,38 @@ mod tests {
         }
     }
 
+    /// Regression for issue #146: pure forward star at default `>` ratio (2:1).
+    /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the
+    /// chain was bipartite; power iteration oscillated and returned the
+    /// uniform initial distribution after an even number of steps. Using the
+    /// paper's degree-based d_max gives every node a positive self-loop and
+    /// the chain converges to the correct stationary distribution.
+    #[test]
+    fn star_topology_winner_at_top_via_subset() {
+        let mut g = mk_group();
+        g.apply_vote(vote(1, "zebra", "alpha", 2, 1));
+        g.apply_vote(vote(2, "zebra", "beta", 2, 1));
+
+        let mut items: Vec<(usize, String)> = g
+            .idx_to_item
+            .iter()
+            .enumerate()
+            .map(|(i, it)| (i, it.as_str().to_string()))
+            .collect();
+        items.sort_by(|a, b| a.1.cmp(&b.1));
+        let idxs: Vec<usize> = items.iter().map(|(i, _)| *i).collect();
+
+        let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);
+        for r in &ranked {
+            eprintln!("{}: {}", r.item.as_str(), r.score);
+        }
+        assert_eq!(
+            ranked[0].item.as_str(),
+            "https://slug.social/zebra",
+            "zebra won both votes and should rank #1"
+        );
+    }
+
     #[test]
     fn group_ranking_cache_dirty_flow() {
         let mut g = mk_group();
diff --git a/test/fixtures/ranking/chain.sorter b/test/fixtures/ranking/chain.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..912a96bdc08f631be27f3c9afc7e05004a2457c0
--- /dev/null
+++ b/test/fixtures/ranking/chain.sorter
@@ -0,0 +1,10 @@
+#t3
+
+~/t3/a { head of chain }
+~/t3/b { middle }
+~/t3/c { tail }
+
+{ a > b }
+~/t3/a > ~/t3/b
+{ b > c }
+~/t3/b > ~/t3/c
diff --git a/test/fixtures/ranking/cycle.sorter b/test/fixtures/ranking/cycle.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..771731ae176e5d77e00ab89767ff2d7465bb7c6b
--- /dev/null
+++ b/test/fixtures/ranking/cycle.sorter
@@ -0,0 +1,12 @@
+#t4
+
+~/t4/a { node a }
+~/t4/b { node b }
+~/t4/c { node c }
+
+{ a > b }
+~/t4/a > ~/t4/b
+{ b > c }
+~/t4/b > ~/t4/c
+{ c > a }
+~/t4/c > ~/t4/a
diff --git a/test/fixtures/ranking/star.sorter b/test/fixtures/ranking/star.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..135c9f9097d57b73c7ab18fac737ed3598d76fbe
--- /dev/null
+++ b/test/fixtures/ranking/star.sorter
@@ -0,0 +1,11 @@
+#repro
+
+~/repro/zebra { winner — beats both others }
+~/repro/alpha { loser — alphabetically first }
+~/repro/beta { loser — alphabetically middle }
+
+{ zebra beats alpha }
+~/repro/zebra > ~/repro/alpha
+
+{ zebra beats beta }
+~/repro/zebra > ~/repro/beta
diff --git a/test/fixtures/ranking/star_inverse.sorter b/test/fixtures/ranking/star_inverse.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..dab842fa924e5124865ee216b9565f6c7471c812
--- /dev/null
+++ b/test/fixtures/ranking/star_inverse.sorter
@@ -0,0 +1,11 @@
+#t2
+
+~/t2/win { source of incoming edges (loses both) }
+~/t2/loss-a { winner }
+~/t2/loss-b { winner }
+
+{ loss-a beats win }
+~/t2/loss-a > ~/t2/win
+
+{ loss-b beats win }
+~/t2/loss-b > ~/t2/win
diff --git a/test/ranking.clj b/test/ranking.clj
new file mode 100644
index 0000000000000000000000000000000000000000..e1358783a84a264ee633bd79151ba29750b717cf
--- /dev/null
+++ b/test/ranking.clj
@@ -0,0 +1,74 @@
+(ns test.ranking
+  "Drives sorterc on .sorter fixtures and asserts ranking properties.
+
+   Regression coverage for issue #146 — pure forward star at default ratio
+   (2:1 for `>`) used to produce tied uniform scores because the random walk
+   on the normalized edge weights was bipartite. Fixed by switching to the
+   degree-based d_max from Negahban–Oh–Shah rank centrality (§3.1)."
+  (:require [clojure.test :refer [deftest is testing]]
+            [babashka.process :as p]
+            [cheshire.core :as json]
+            [clojure.java.io :as io]))
+
+(def sorterc-bin
+  "Path to the locally-built sorterc binary. Builds on demand if missing."
+  (let [dbg     "target/debug/sorterc"
+        release "target/release/sorterc"]
+    (cond
+      (.exists (io/file release)) release
+      (.exists (io/file dbg))     dbg
+      :else
+      (do (println "building sorterc…")
+          (let [r (p/shell {:out :string :err :string :continue true}
+                           "cargo build -p sorterc")]
+            (when-not (zero? (:exit r))
+              (throw (ex-info "cargo build -p sorterc failed"
+                              {:stderr (:err r)}))))
+          dbg))))
+
+(defn compile-sorter [fixture-path]
+  (let [{:keys [out exit]} (p/shell {:out :string :err :string :continue true}
+                                    sorterc-bin "compile" fixture-path)]
+    (when-not (zero? exit)
+      (throw (ex-info "sorterc exit nonzero" {:fixture fixture-path :out out})))
+    (json/parse-string out true)))
+
+(defn first-component-ranking [result]
+  (-> result :rankings first :components first :ranking))
+
+(defn item-leaf [item]
+  (last (clojure.string/split item #"/")))
+
+(deftest chain-ranks-head-first
+  (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/chain.sorter"))
+        names   (mapv (comp item-leaf :item) ranking)]
+    (is (= ["a" "b" "c"] names)
+        "chain a>b>c should rank a, b, c in order")
+    (is (apply > (map :score ranking))
+        "scores should attenuate strictly down the chain")))
+
+(deftest inverse-star-puts-winners-on-top
+  (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/star_inverse.sorter"))
+        names   (mapv (comp item-leaf :item) ranking)]
+    (is (= "win" (last names))
+        "the item that lost to both others should be ranked last")))
+
+(deftest cycle-produces-uniform-scores
+  (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/cycle.sorter"))
+        scores  (map :score ranking)]
+    (is (every? #(< (Math/abs (- % 1/3)) 1e-3) scores)
+        "a perfectly symmetric 3-cycle should give every node ~1/3")))
+
+(deftest star-topology-winner-at-top
+  ;; Issue #146 regression: source-only star at default `>` ratio (2:1).
+  ;; Pre-fix produced uniform 1/3 scores; alphabetical fallback put the
+  ;; unambiguous winner at the bottom. Post-fix the chain is aperiodic and
+  ;; converges to π_zebra = 1/2, π_alpha = π_beta = 1/4.
+  (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/star.sorter"))
+        names   (mapv (comp item-leaf :item) ranking)
+        by-name (into {} (map (juxt (comp item-leaf :item) :score) ranking))]
+    (is (= "zebra" (first names))
+        "zebra won both votes and should rank #1")
+    (is (< (Math/abs (- (by-name "zebra") 0.5)) 1e-3))
+    (is (< (Math/abs (- (by-name "alpha") 0.25)) 1e-3))
+    (is (< (Math/abs (- (by-name "beta") 0.25)) 1e-3))))

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.