constitution · epochs · watch · epoch 3

comparison

c_afa638171cf7 (tommy-mor) vs c_48aeaf9b52c3 (tommy-mor)

download prompt · raw event · cmp_bc2931f95d4603

council reasoning

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

Side B fixes a real, well-diagnosed correctness bug in the core ranking algorithm (wrong divisor causing bipartite oscillation and incorrect rankings), grounded in the cited paper, with focused regression tests across multiple topologies. Side A adds a substantial new feature (Reddit OAuth linking, UUID-centric identity) with reasonable design, but is larger, more speculative in scope (privacy/trust semantics, mock harness churn), and carries more risk/noise relative to its lasting correctness value than B's precise algorithmic fix.

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

B fixes a core Rank Centrality correctness bug (weight-sum d_max → degree-based d_max per Negahban–Oh–Shah), restoring proper stationary scores on star graphs instead of bipartite oscillation back to uniform ties—directly fixing the product’s ranking output, with targeted Rust and fixture-driven Clojure regressions. A is a large, solid identity feature (Reddit OAuth, multi-link UUID principals, private linked-providers UI, pending-weight batch fix), but it extends auth rather than repairing the central ranking math the project is built around.

openai/gpt-chat-latest · winner B · 3:2 · permalink

Side B fixes a correctness bug in the core ranking algorithm by replacing the normalization divisor with the canonical degree-based d_max, eliminating oscillation in star-topology graphs and restoring correct stationary rankings. It also adds focused Rust and end-to-end regression tests for star, inverse-star, chain, and cycle cases, whereas Side A is a substantial feature expansion (Reddit OAuth linking and UUID-based identity flow) with broader surface area but less direct evidence of fixing an existing correctness issue.

sides

A — c_afa638171cf7 (tommy-mor)

message

[52f5c51c] Add Reddit OAuth linking and make UUID the only account identity.

OAuth providers only attach to a session UUID (first link creates the
principal); linked providers stay private on the account page.

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

diff preview

diff --git a/AGENTS.md b/AGENTS.md
index 6e0fd8ebb65d665c9c1438e3275971d62b98fd95..e9cc3173dbeb21ad0fc090ca7b407b027c7820a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,8 +35,11 @@ Environment variables (defaults in `server/src/state.rs`):
 - `SORTER2_DATA_DIR` — default `./data` (created on startup)
 - `SORTER2_EVENT_LOG` — default `{data_dir}/events.jsonl`
 - `SORTER2_BASE_URL` — public origin (also drives Secure cookies when `https://`)
-- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth (optional; login disabled if unset)
-- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` (tests only)
+- `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` — GitHub OAuth linking (optional)
+- `REDDIT_CLIENT_ID` / `REDDIT_CLIENT_SECRET` (or `REDDIT_APP_*`) — Reddit API import + OAuth linking (optional)
+- `SORTER2_ALLOW_MOCK_OAUTH=1` — allow `mock_user` on `/auth/github` and `/auth/reddit` (tests only)
+
+Identity: UUID is canonical. OAuth providers only *link* to a UUID (first link creates the principal). Linked providers are private to the account owner.
 
 Health check: `GET /healthz` → `ok`.
 
diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs
index 5906f93b13853421e96a3c37bc9d8202a47842bf..c706ae8045a811e5941f5f6c72da88f42a403a82 100644
--- a/server/src/auth/mod.rs
+++ b/server/src/auth/mod.rs
@@ -1,4 +1,8 @@
-//! GitHub OAuth login, session cookies, and vote actor resolution.
+//! OAuth linking, session cookies, and vote actor resolution.
+//!
+//! Canonical identity is a UUID. OAuth providers only *link* to that UUID
+//! (first link creates the principal; later links attach while logged in).
+//! Which providers are linked is private to the account owner.
 
 pub mod config;
 pub mod identity;
@@ -22,7 +26,9 @@ use crate::{
     form_template::template_json_compact,
     html::layout,
     state::AppState,
-    storage_schema::{oauth_link_owner, pseudonym_owner, Store, StoreFields},
+    storage_schema::{
+        linked_providers_for_uuid, oauth_link_owner, pseudonym_owner, Store, StoreFields,
+    },
     ui_action::UI_RPC_FIELD,
 };
 
@@ -53,10 +59,12 @@ fn new_actor_uuid() -> String {
 pub struct LoginQuery {
     #[serde(default)]
     pub return_to: Option<String>,
+    #[serde(default)]
+    pub error: Option<String>,
 }
 
 #[derive(Debug, Deserialize)]
-pub struct GitHubStartQuery {
+pub struct OAuthStartQuery {
     #[serde(default)]
     pub return_to: Option<String>,
     #[serde(default)]
@@ -72,15 +80,22 @@ fn return_from_query_or_jar(jar: &CookieJar, query: Option<&str>) -> String {
         .unwrap_or_else(|| "/".to_string())
 }
 
-fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, String)> {
+/// Available OAuth link targets: `(provider_key, label, start_href)`.
+fn oauth_providers(base_url: &str, return_to: &str) -> Vec<(&'static str, &'static str, String)> {
     let mut out = Vec::new();
+    let enc = urlencoding::encode(return_to);
     if oauth::GitHubConfig::from_env(base_url).is_some() {
         out.push((
-            "GitHub",
-            format!(
-                "/auth/github?return_to={}",
-                urlencoding::encode(return_to)
-            ),
+            "github",
+            oauth::provider_label("github"),
+            format!("/auth/github?return_to={enc}"),
+        ));
+    }
+    if oauth::RedditConfig::from_env(base_url).is_some() {
+        out.push((
+            "reddit",
+            oauth::provider_label("reddit"),
+            format!("/auth/reddit?return_to={enc}"),
         ));
     }
     out
@@ -125,23 +140,41 @@ fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result<Markup, Stat
     })
 }
 
-fn signed_out_body(providers: &[(&str, String)]) -> Markup {
+fn login_error_message(code: Option<&str>) -> Option<&'static str> {
+    match code {
+        Some("oauth_taken") => {
+            Some("that OAuth account is already linked to a different sorter2 account")
+        }
+        Some("oauth_failed") => Some("OAuth failed — try again"),
+        _ => None,
+    }
+}
+
+fn signed_out_body(
+    providers: &[(&str, &str, String)],
+    error: Option<&str>,
+) -> Markup {
     html! {
         main class="panel login-page" {
             section class="login-section" {
                 h1 { "sign in" }
-                p class="muted" { "link an account to vote under a lasting alias" }
+                p class="muted" {
+                    "link an OAuth account to create your identity, then claim an alias to vote"
+                }
+                @if let Some(msg) = login_error_message(error) {
+                    p class="alias-bad" data-testid="login-error" { (msg) }
+                }
                 @if providers.is_empty() {
                     p class="muted" {
-                        "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET."
+                        "OAuth is not configured. Set GitHub and/or Reddit client credentials."
                     }
                 } @else {
                     ul class="oauth-provider-list" {
-                        @for (name, href) in providers {
+                        @for (key, label, href) in providers {
                             li {
                                 a href=(href) class="btn-primary oauth-provider"
-                                    data-testid=(format!("oauth-{}", name.to_lowercase())) {
-                                    (format!("Continue with {name}"))
+                                    data-testid=(format!("oauth-{key}")) {
+                                    (format!("Link {label}"))
                                 }
                             }
                         }
@@ -156,7 +189,10 @@ fn signed_out_body(providers: &[(&str, String)]) -> Markup {
 fn account_body(
     actor: &session::SessionActor,
     aliases: &[String],
-    providers: &[(&str, String)],
+    // Provider keys already linked to this UUID (private).
+    linked: &[String],
+    // Providers available to link: not yet attached.
+    unlinkable: &[(&str, &str, String)],
     claim_forms: Markup,
 ) -> Markup {
     let current = actor.pseudonym.trim();
@@ -212,16 +248,29 @@ fn account_body(
                 (claim_forms)
             }
 
-            @if !providers.is_empty() {
-                section class="login-section" {
-                    h2 { "linked sign-in" }
-                    p class="muted small" { "sign in again with the same provider to return to this account" }
+            section class="login-section" {
+                h2 { "linked sign-in" }
+                p class="muted small" {
+                    "private to you — linking more providers raises trust weight without publishing which accounts you use"
+                }
+                @if linked.is_empty() {
+                    p class="muted" data-testid="linked-providers-empty" { "none yet" }
+                } @else {
+                    ul class="linked-provider-list" data-testid="linked-providers" {
+                        @for key in linked {
+                            li data-testid=(format!("linked-{key}")) {
+                                (oauth::provider_label(key))
+                            }
+                        }
+                    }
+                }
+                @if !unlinkable.is_empty() {
                     ul class="oauth-provider-list" {
-                        @for (name, href) in providers {
+                        @for (key, label, href) in unlinkable {
                             li {
                                 a href=(href) class="btn-secondary oauth-provider"
-                                    data-testid=(format!("oauth-relink-{}", name.to_lowercase())) {
-                                    (format!("Re-link {name}"))
+                                    data-testid=(format!("oauth-link-{key}")) {
+                                    (format!("Link {label}"))
                                 }
                             }
                         }
@@ -243,12 +292,21 @@ fn account_body(
 fn login_body(
     session: Option<&session::SessionActor>,
     aliases: &[String],
-    providers: &[(&str, String)],
+    linked: &[String],
+    providers: &[(&str, &str, String)],
     claim_forms: Option<Markup>,
+    error: Option<&str>,
 ) -> Markup {
     match (session, claim_forms) {
-        (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms),
-        _ => signed_out_body(providers),
+        (Some(actor), Some(forms)) => {
+            let unlinkable: Vec<_> = providers
+                .iter()
+                .filter(|(key, _, _)| !linked.iter().any(|p| p == key))
+                .cloned()
+                .collect();
+            account_body(actor, aliases, linked, &unlinkable, forms)
+        }
+        _ => signed_out_body(providers, error),
     }
 }
 
@@ -268,6 +326,10 @@ pub async fn login_page(
         .as_ref()
         .map(|s| alias_list(db, &s.uuid))
         .unwrap_or_default();
+    let linked = session
+        .as_ref()
+        .map(|s| linked_providers_for_uuid(db, &s.uuid).unwrap_or_default())
+        .unwrap_or_default();
     let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to);
 
     let claim_forms = if session.is_some() {
@@ -282,7 +344,14 @@ pub async fn login_page(
         } else {
             "login · sorter2"
         },
-        login_body(session.as_ref(), &aliases, &providers, claim_forms),
+        login_body(
+            session.as_ref(),
+            &aliases,
+            &linked,
+            &providers,
+            claim_forms,
+            query.error.as_deref(),
+        ),
         state.views.get_views("/login"),
         session
             .as_ref()
@@ -302,7 +371,6 @@ pub async fn alias_page(
     let db = state.projection_store.db();
     let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?;
     if session::session_has_pseudonym(&session) {
-        // Already onboarded — manage aliases on the account page.
         return Ok(Redirect::to("/login").into_response());
     }
 
@@ -331,7 +399,7 @@ pub async fn alias_page(
 pub async fn github_start(
     State(state): State<AppState>,
     jar: CookieJar,
-    Query(query): Query<GitHubStartQuery>,
+    Query(query): Query<OAuthStartQuery>,
 ) -> Result<Response, StatusCode> {
     let cfg = oauth::GitHubConfig::from_env(&base_url_from_env(state.cfg.port))
         .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
@@ -342,7 +410,28 @@ pub async fn github_start(
     } else {
         None
     };
-    let url = oauth::authorize_url(&cfg, &state_token, mock_user);
+    let url = oauth::github_authorize_url(&cfg, &state_token, mock_user);
+    let jar = jar
+        .add(session::oauth_state_cookie_value(&state_token))
+        .add(session::auth_return_cookie_value(&return_to));
+    Ok((jar, Redirect::temporary(&url)).into_response())
+}
+
+pub async fn reddit_start(
+    State(state): State<AppState>,
+    jar: CookieJar,
+    Query(query): Query<OAuthStartQuery>,
+) -> Result<Response, StatusCode> {
+    let cfg = oauth::RedditConfig::from_env(&base_url_from_env(state.cfg.port))
+        .ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
+    let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref());
+    let state_token = session::new_oauth_state();
+    let mock_user = if config::mock_oauth_allowed() {
+        query.mock_user.as_deref()
+    } else {
+        None
+    };
+    let url = oauth::reddit_authorize_url(&cfg, &state_token, mock_user);
     let jar = jar
         .add(session::oauth_state_cookie_value(&state_token))
         .add(session::auth_return_cookie_value(&return_to));
@@ -355,6 +444,13 @@ pub struct OAuthCallbackQuery {
     pub state: String,
 }
 
+/// Link `provider:provider_id` to a UUID.
+///
+/// - Logged in + new provider → attach to session UUID
+/// - Logged in + already ours → no-op
+/// - Logged in 

… preview truncated; 29,823 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.