constitution · epochs · watch · epoch 3

comparison

c_55f1cdf12e22 (tommy-mor) vs c_48aeaf9b52c3 (tommy-mor)

download prompt · raw event · cmp_2ceab3e940d439

council reasoning

~anthropic/claude-sonnet-latest · winner B · 3:2 · permalink

B fixes a genuine, well-diagnosed algorithmic bug (bipartite Markov chain oscillation in rank-centrality scoring) with a principled correction grounded in cited literature, plus targeted regression tests across multiple topologies. A adds a large, plausible-but-sprawling invite-system feature with meaningful surface area (server state, RPC, CLI, reducer, timeline) but is riskier/less proven, includes speculative unused fields (InviteMinted/InviteRedeemed events never emitted, room_timeline/ActiveInviteState seemingly unused by the new timeline.rs), and its correctness is less rigorously validated than B's mathematically justified fix.

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

B fixes a real core Rank Centrality bug (wrong d_max caused bipartite oscillation and uniform scores on star topologies), matching the paper and adding tight Rust/Clojure regressions—lasting correctness of the product’s ranking. A ships a large invite/audit feature with CLI and tests, but much of it is broader surface area and dual in-memory vs event-log invite state rather than a comparably foundational design fix.

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

Side B fixes a core ranking algorithm bug by changing Rank Centrality to use degree-based d_max instead of summed edge weights, eliminating oscillation in star-topology graphs and producing correct stable rankings. It also adds focused regression tests (Rust and Clojure fixtures) that directly reproduce and guard against the failure, whereas Side A introduces substantial invite and audit functionality but also leaves newly added invite events unused in favor of ephemeral in-memory state, making the design less durable.

sides

A — c_55f1cdf12e22 (tommy-mor)

message

[00be3a29] invite system

diff preview

diff --git a/bb.edn b/bb.edn
index 50be8232847e672b3f273a2fb25ddd1d12adb7e2..818f850765370d12d58f239e287764fc3649d78b 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,14 +47,16 @@
                                            "RUST_LOG"      "info"})})))}
 
   test
-  {:doc "Full test suite: integration + auth + grants"
+  {:doc "Full test suite: integration + auth + grants + invites"
    :requires ([test.integration :as integration]
               [test.auth :as auth]
-              [test.grants :as grants])
+              [test.grants :as grants]
+              [test.invites :as invites])
    :task (do
            (integration/integration)
            (auth/auth-test)
-           (grants/grants-test))}
+           (grants/grants-test)
+           (invites/invites-test))}
 
   perf
   {:doc "Performance test: concurrent HTTP requests to detect blocking I/O"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index e5833b0b93d8e667b94c574ba2b0f8cb758ff3df..8eda9f485bd1f7392f1e34be27176c21e20354eb 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -105,6 +105,23 @@ enum ScopedCmd {
         #[arg(long)]
         json: bool,
     },
+
+    /// Mint a shareable invite link (24h TTL, in-memory until redeemed). Requires Manage on the room.
+    InviteLink {
+        /// Comma-separated: view, post, vote, add_item, manage
+        #[arg(long = "caps", value_delimiter = ',')]
+        caps: Vec<String>,
+        #[arg(long, default_value_t = 1)]
+        uses: usize,
+        #[arg(long)]
+        json: bool,
+    },
+
+    /// List principals granted access in this room (requires View or Manage)
+    Audit {
+        #[arg(long)]
+        json: bool,
+    },
 }
 
 #[derive(Subcommand, Debug)]
@@ -514,17 +531,35 @@ fn print_thread(resp: &ThreadDetailResponse) {
         .duration_since(std::time::UNIX_EPOCH)
         .unwrap_or_default()
         .as_millis() as i64;
-    if resp.total > resp.posts.len() {
-        let end = resp.offset + resp.posts.len();
-        eprintln!("# showing {}-{} of {} posts  (--offset N --limit N to paginate)", resp.offset, end.saturating_sub(1), resp.total);
+    if resp.total > resp.items.len() {
+        let end = resp.offset + resp.items.len();
+        eprintln!(
+            "# showing {}-{} of {} rows  (--offset N --limit N to paginate)",
+            resp.offset,
+            end.saturating_sub(1),
+            resp.total
+        );
     }
-    for (i, post) in resp.posts.iter().enumerate() {
-        let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts);
-        let body = &post.body.trim();
-        println!("<post index=\"{}\" timeago=\"{}\">", post.index, timeago);
-        println!("{}", body);
-        println!("</post>");
-        if i + 1 < resp.posts.len() {
+    for (i, item) in resp.items.iter().enumerate() {
+        match item {
+            ThreadItem::Post {
+                index,
+                ts,
+                body,
+                ..
+            } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                let body = body.trim();
+                println!("<post index=\"{}\" timeago=\"{}\">", index, timeago);
+                println!("{}", body);
+                println!("</post>");
+            }
+            ThreadItem::System { ts, text } => {
+                let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);
+                println!("<system timeago=\"{}\">{}</system>", timeago, text.trim());
+            }
+        }
+        if i + 1 < resp.items.len() {
             println!();
             println!();
         }
@@ -1036,6 +1071,95 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {
                 }
             }
         },
+        ScopedCmd::InviteLink { caps, uses, json } => {
+            let caps: Vec<String> = caps
+                .into_iter()
+                .flat_map(|s| {
+                    s.split(',')
+                        .map(|p| p.trim().to_lowercase())
+                        .filter(|p| !p.is_empty())
+                        .collect::<Vec<_>>()
+                })
+                .collect();
+            if caps.is_empty() {
+                return Err(anyhow!("--caps is required (e.g. --caps view,post,vote)"));
+            }
+            let bearer = effective_bearer().ok_or_else(|| {
+                anyhow!(
+                    "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                     then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                )
+            })?;
+            let batch = send_rpc(
+                &client,
+                base,
+                Some(&bearer),
+                vec![RpcCommand::RoomMintInvite {
+                    room: room.to_string(),
+                    capabilities: caps,
+                    max_uses: uses,
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomInviteMinted {
+                    invite_url,
+                    expires_at_ms,
+                    max_uses,
+                } => {
+                    if json {
+                        println!(
+                            "{}",
+                            serde_json::to_string_pretty(&serde_json::json!({
+                                "invite_url": invite_url,
+                                "expires_at_ms": expires_at_ms,
+                                "max_uses": max_uses,
+                            }))?
+                        );
+                    } else {
+                        println!("{invite_url}");
+                        println!("(Expires in 24 hours. Max uses: {max_uses})");
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
+        ScopedCmd::Audit { json } => {
+            let bearer = effective_bearer().ok_or_else(|| {
+                anyhow!(
+                    "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+                     then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+                )
+            })?;
+            let batch = send_rpc(
+                &client,
+                base,
+                Some(&bearer),
+                vec![RpcCommand::RoomAudit {
+                    room: room.to_string(),
+                }],
+            )
+            .await?;
+            match rpc_line_ok(&batch.results[0])? {
+                RpcResult::RoomAudit(resp) => {
+                    if json {
+                        println!("{}", serde_json::to_string_pretty(&resp)?);
+                    } else {
+                        println!("room {}", resp.room);
+                        if resp.grants.is_empty() {
+                            println!("(no grants recorded)");
+                        } else {
+                            let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0);
+                            for g in &resp.grants {
+                                let caps = g.capabilities.join(", ");
+                                println!("{:<width$}  {}", g.username, caps, width = w_user.max(8));
+                            }
+                        }
+                    }
+                }
+                _ => return Err(anyhow!("unexpected RPC result")),
+            }
+        }
         ScopedCmd::Check { file, json } => {
             let mut text = String::new();
             match file {
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 995ce4a61d29b024c399c656134f541ecfd880cf..b45ba39419c84af8bf2333fc9b7d47e98525c45f 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,12 +12,61 @@ use tokio::sync::RwLock;
 
 use crate::{
     api::helpers::{api_error, now_ms, sha256_hex},
-    events::{Event, TokenIssued, UserRegistered},
+    events::{Event, GrantAdded, TokenIssued, UserRegistered},
     identity::{parse_agent, parse_username},
     html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
     state::{AppState, PendingSession},
 };
 
+/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
+const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
+
+async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
+    let now = now_ms();
+    let ga = {
+        let mut invites = state.invites.write().await;
+        let Some(inv) = invites.get_mut(invite_token) else {
+            return Err("invite not found".into());
+        };
+        if now > inv.expires_at_ms {
+            invites.remove(invite_token);
+            return Err("invite expired".into());
+        }
+        if inv.current_uses >= inv.max_uses {
+            return Err("invite exhausted".into());
+        }
+        inv.current_uses += 1;
+        Event::GrantAdded(GrantAdded {
+            ts: now,
+            room_id: inv.room_id.clone(),
+            username: grantee_username.to_string(),
+            capabilities: inv.capabilities.clone(),
+            granted_by: inv.inviter.clone(),
+        })
+    };
+
+    match state.event_log.append(&ga).await {
+        Ok(()) => {
+            let mut reduced = state.reduced.write().await;
+            reduced.apply_event(ga);
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get(invite_token) {
+                if inv.current_uses >= inv.max_uses {
+                    invites.remove(invite_token);
+                }
+            }
+            Ok(())
+        }
+        Err(e) => {
+            let mut invites = state.invites.write().await;
+            if let Some(inv) = invites.get_mut(invite_token) {
+                inv.current_uses = inv.current_uses.saturating_sub(1);
+            }
+            Err(format!("{e}"))
+        }
+    }
+}
+
 fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSession>>> {
     state.pending_sessions.clone()
 }
@@ -115,6 +164,42 @@ pub struct AuthLoginQuery {
     pub session: String,
 }
 
+pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+    let token = token.trim().to_string();
+    if token.is_empty() {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+    let now = now_ms();
+    let valid = {
+        let invites = state.invites.read().await;
+        match invites.get(&token) {
+            None => false,
+            Some(inv) => now <= inv.expires_at_ms && inv.current_uses < inv.max_uses,
+        }
+    };
+    if !valid {
+        return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
+    }
+
+    let session = format!("p_{}", uuid::Uuid::new_v4().simple());
+    let s = PendingSession {
+        agent: INVITE_BROWSER_AGENT.to_string(),
+        created_ts: now_ms(),
+        provider: None,
+        provider_id: None,
+        redeem_invite: Some(token),
+        complete: None,
+    };
+    state.pending_sessions.write().await.insert(session.clone(), s);
+
+    let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
+    Redirect::temporary(&format!(
+        "{public_url}/auth/login?session={}",
+        urlencoding::encode(&session)
+    ))
+    .into_response()
+}
+
 pub async fn get_auth_login(Query(q): Query<AuthLoginQuery>, State(state): State<AppState>) -> impl IntoResponse {
     // Redirect to Google auth endpoint.
     let sessions = pending_sessions(&state);
@@ -205,6 +290,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
         s.provider = Som

… preview truncated; 45,403 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.