Side B fixes an actual correctness bug in the core ranking algorithm (wrong divisor causing bipartite oscillation and incorrect rankings on star topologies), grounds the fix in a cited paper, and adds thorough regression tests (Rust unit test plus Clojure fixtures covering star/inverse-star/chain/cycle topologies). Side A is mostly a demo-counter removal/refactor plus introduction of an async settlement worker with caching, which is reasonable housekeeping but doesn't fix a bug and carries more churn/deleted test coverage without a comparably rigorous justification.
constitution · epochs · watch · epoch 3
c_4772ee88dbe3 (tommy-mor) vs c_48aeaf9b52c3 (tommy-mor)
download prompt · raw event · cmp_127f7e579c7022
council reasoning
B fixes a core correctness bug in compute_scores_from_edges (weight-sum d_max → unweighted degree d_max per Negahban–Oh–Shah), so star topologies converge instead of oscillating to uniform scores, with Rust and Clojure fixture regression coverage. A is strong lasting design—demo-counter removal plus a batched SettlementClient and read-only ranked_items_cached path—but it mainly restructures how votes are applied and ranked, whereas B makes the ranking results themselves trustworthy.
Side B fixes a correctness bug in the core ranking algorithm by changing the Rank Centrality transition matrix to use degree-based d_max instead of summed edge weights, preventing oscillation on star topologies and producing the expected stationary distribution. It also adds targeted Rust and end-to-end regression tests with ranking fixtures, whereas Side A is largely a refactor that removes the demo counter and introduces cached ranking/settlement infrastructure without an equally clear correctness improvement.
sides
A — c_4772ee88dbe3 (tommy-mor)
message
[07715165] nice
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index c3c62a76f424010d77a6090c84dd0b82098f573e..da2536112faea313352624cf2ce0ddd0ab3377c1 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,7 @@ use axum::{
use std::collections::HashMap;
use crate::{
- html::{demo_counter_panel, js_string_literal, ranking_panel, JsBuilder},
+ html::{js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
parser_render::parser_panel_morph,
state::AppState,
@@ -35,13 +35,6 @@ pub async fn post_ui_html(
};
match action {
- HtmlUiAction::BumpDemoCounter => {
- let count = state.bump_demo_counter().await;
- let panel = demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref());
- JsBuilder::new()
- .morph_selector("#demo-counter-panel", panel)
- .into_response()
- }
HtmlUiAction::RecordVote {
a,
b,
@@ -54,8 +47,8 @@ pub async fn post_ui_html(
{
return ui_js_warn(&e).into_response();
}
- let mut group = state.group.write().await;
- let panel = ranking_panel(&mut group);
+ let group = state.group.read().await;
+ let panel = ranking_panel(&group);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
@@ -87,20 +80,6 @@ mod tests {
assert!(matches!(err, HtmlUiParseError::MissingRpc));
}
- #[test]
- fn bump_action_deserializes() {
- let template = serde_json::json!({ "action": "bump_demo_counter" });
- let mut form = HashMap::new();
- form.insert(
- UI_RPC_FIELD.to_string(),
- serde_json::to_string(&template).unwrap(),
- );
- assert_eq!(
- parse_html_ui_from_form(&form).unwrap(),
- HtmlUiAction::BumpDemoCounter
- );
- }
-
#[test]
fn record_vote_action_deserializes() {
let template = serde_json::json!({
diff --git a/server/src/events.rs b/server/src/events.rs
index b969242534e184d4f0a689543a479670b08a18df..eff80aef0257f706d2341f666e63d6a3d921bf6e 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -5,8 +5,6 @@ use serde::{Deserialize, Serialize};
pub enum Event {
/// Page view recorded (path → counter in views.json).
ViewRecorded { path: String, ts: i64 },
- /// Demo counter bump from `POST /ui` (persisted in the single JSONL log).
- DemoCounterBumped { ts: i64, value: u64 },
/// Pairwise comparison vote (replayed into [`crate::reducer::GroupState`] on boot).
VoteRecorded {
ts: i64,
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 5b1d0b5a887d89e7e80796aa7a6c8ed5baaf2782..d69ed962b5c8625bc83c933b1825f2cc1d0868e2 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -13,7 +13,7 @@ use crate::{
form_template::template_json_compact,
parser_action::ParserAction,
parser_render::parser_panel,
- ranking::ranked_items,
+ ranking::ranked_items_cached,
reducer::GroupState,
state::AppState,
ui_action::UI_RPC_FIELD,
@@ -199,10 +199,8 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
}
}
-pub fn ranking_panel(group: &mut GroupState) -> Markup {
- const MAX_ITERS: usize = 10_000;
- const TOL: f64 = 1e-8;
- let items = ranked_items(group, MAX_ITERS, TOL);
+pub fn ranking_panel(group: &GroupState) -> Markup {
+ let items = ranked_items_cached(group);
html! {
section id="ranking-panel" class="demo-panel" {
h2 { "Ranking" }
@@ -260,35 +258,6 @@ pub fn vote_panel() -> Markup {
}
-pub fn demo_counter_panel(count: u64, event_log_path: &str) -> Markup {
- let rpc = template_json_compact(&serde_json::json!({ "action": "bump_demo_counter" }))
- .expect("rpc json");
- html! {
- section id="demo-counter-panel" class="demo-panel" {
- h1 { "sorter2" }
- p class="muted" {
- "Pairwise ranking scaffold — votes persist to JSONL and replay on boot."
- }
- p class="demo-count" {
- strong { "Counter: " }
- span id="demo-count-value" { (count) }
- }
- p class="muted small" {
- "Event log: " code { (event_log_path) }
- }
- form method="post" action="/ui" id="demo-bump-form" {
- input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
- button type="submit" class="btn-primary" { "Bump (POST /ui → eval JS)" }
- }
- p class="muted small" {
- "Uses hidden "
- code { "__rpc__" }
- " JSON + Idiomorph morph — no full page reload."
- }
- }
- }
-}
-
pub async fn home(
State(state): State<AppState>,
jar: CookieJar,
@@ -297,16 +266,15 @@ pub async fn home(
let path = uri.path().to_string();
state.views.increment(path.clone());
let views = state.views.get_views(&path);
- let count = *state.demo_counter.read().await;
let theme = theme_from_jar(&jar);
let theme_next = theme_next_from_uri(&uri);
- let mut group = state.group.write().await;
+ let group = state.group.read().await;
let empty_action = ParserAction::suggest(String::new(), None);
let body = html! {
+ h1 { "sorter2" }
(parser_panel("", &empty_action))
(vote_panel())
- (ranking_panel(&mut group))
- (demo_counter_panel(count, state.event_log.path().to_string_lossy().as_ref()))
+ (ranking_panel(&group))
};
layout("sorter2", body, views, theme, &theme_next)
}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 6716c5b282e7980a7a0f03d63ad8b25eda61cc55..fa423640d598f4ba97a5885d228e78d7b97f7a22 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -9,6 +9,7 @@ pub mod parser_render;
pub mod path_types;
pub mod ranking;
pub mod reducer;
+pub mod settlement;
pub mod state;
pub mod ui_action;
pub mod views;
diff --git a/server/src/ranking.rs b/server/src/ranking.rs
index 89d3280126a8d8f841721ce8cb63ff735d68752a..2d706762792ba9239bb3f1c2e4974a2fde908013 100644
--- a/server/src/ranking.rs
+++ b/server/src/ranking.rs
@@ -91,6 +91,11 @@ pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64)
pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec<RankedItem> {
compute_group_ranking(group, max_iters, tol);
+ ranked_items_cached(group)
+}
+
+/// Read cached scores without recomputing (HTTP fast path).
+pub fn ranked_items_cached(group: &GroupState) -> Vec<RankedItem> {
let mut items: Vec<RankedItem> = group
.idx_to_item
.iter()
@@ -105,7 +110,12 @@ pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec<R
items
}
-fn compute_scores_from_edges(n: usize, edges: impl Iterator<Item = ((usize, usize), f64)>, max_iters: usize, tol: f64) -> Vec<f64> {
+pub fn compute_scores_from_edges(
+ n: usize,
+ edges: impl Iterator<Item = ((usize, usize), f64)>,
+ max_iters: usize,
+ tol: f64,
+) -> Vec<f64> {
if n == 0 {
return vec![];
}
diff --git a/server/src/settlement.rs b/server/src/settlement.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1f722ceaea62cda22c28ab71551f259fbf049b81
--- /dev/null
+++ b/server/src/settlement.rs
@@ -0,0 +1,114 @@
+use std::sync::Arc;
+
+use tokio::sync::{mpsc, oneshot, RwLock};
+
+use crate::{
+ event_log::EventLog,
+ events::Event,
+ ranking::compute_scores_from_edges,
+ reducer::{GroupState, VoteData},
+};
+
+const MAX_ITERS: usize = 10_000;
+const TOL: f64 = 1e-8;
+
+pub struct SettlementCommand {
+ pub vote: VoteData,
+ pub event: Event,
+ pub reply: oneshot::Sender<Result<(), String>>,
+}
+
+#[derive(Clone)]
+pub struct SettlementClient {
+ tx: mpsc::Sender<SettlementCommand>,
+}
+
+impl SettlementClient {
+ pub fn spawn(group: Arc<RwLock<GroupState>>, event_log: Arc<EventLog>) -> Self {
+ let (tx, rx) = mpsc::channel(64);
+ tokio::spawn(settlement_worker(rx, group, event_log));
+ Self { tx }
+ }
+
+ pub async fn record_vote(&self, vote: VoteData, event: Event) -> Result<(), String> {
+ let (reply, rx) = oneshot::channel();
+ self.tx
+ .send(SettlementCommand {
+ vote,
+ event,
+ reply,
+ })
+ .await
+ .map_err(|_| "settlement worker stopped".to_string())?;
+ rx.await
+ .map_err(|_| "settlement worker stopped".to_string())?
+ }
+}
+
+async fn settlement_worker(
+ mut rx: mpsc::Receiver<SettlementCommand>,
+ group: Arc<RwLock<GroupState>>,
+ event_log: Arc<EventLog>,
+) {
+ while let Some(first) = rx.recv().await {
+ let mut batch = vec![first];
+ while let Ok(more) = rx.try_recv() {
+ batch.push(more);
+ }
+
+ let mut disk_err: Option<String> = None;
+ for cmd in &batch {
+ if let Err(e) = event_log.append(&cmd.event).await {
+ disk_err = Some(e.to_string());
+ break;
+ }
+ }
+
+ if let Some(err) = disk_err {
+ for cmd in batch {
+ let _ = cmd.reply.send(Err(err.clone()));
+ }
+ continue;
+ }
+
+ let (edges, n) = {
+ let mut w = group.write().await;
+ for cmd in &batch {
+ w.apply_vote(cmd.vote.clone());
+ }
+ (w.edges.clone(), w.idx_to_item.len())
+ };
+
+ let new_scores = compute_scores_from_edges(
+ n,
+ edges.iter().map(|(&k, &v)| (k, v)),
+ MAX_ITERS,
+ TOL,
+ );
+
+ {
+ let mut w = group.write().await;
+ w.cached_scores = new_scores;
+ w.dirty = false;
+ }
+
+ for cmd in batch {
+ let _ = cmd.reply.send(Ok(()));
+ }
+ }
+}
+
+/// Compute ranking cache from current in-memory edges (startup replay only).
+pub fn warm_ranking_cache(group: &mut GroupState) {
+ if !group.dirty {
+ return;
+ }
+ let n = group.idx_to_item.len();
+ group.cached_scores = compute_scores_from_edges(
+ n,
+ group.edges.iter().map(|(&k, &v)| (k, v)),
+ MAX_ITERS,
+ TOL,
+ );
+ group.dirty = false;
+}
diff --git a/server/src/state.rs b/server/src/state.rs
index 8ec9902e2ecc31cf8208f7ad6365891dc5537eed..1922541a4064c2de1df2d993a461cae783320e05 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -6,6 +6,7 @@ use crate::{
event_log::EventLog,
events::Event,
reducer::{GroupState, VoteData},
+ settlement::{warm_ranking_cache, SettlementClient},
views::ViewStore,
};
@@ -38,8 +39,8 @@ pub struct AppState {
pub cfg: Arc<AppConfig>,
pub event_log: Arc<EventLog>,
pub views: ViewStore,
- pub demo_counter: Arc<RwLock<u64>>,
pub group: Arc<RwLock<GroupState>>,
+ settlement: SettlementClient,
}
impl AppState {
@@ -48,14 +49,10 @@ impl AppState {
let views_path = format!("{}/views.json", cfg.data_dir);
let views = ViewStore::new(&views_path);
- let mut demo_counter: u64 = 0;
let mut group = GroupState::new();
if let Ok((events, _)) = event_log.load_all().await {
for ev in events {
match ev {
- Event::DemoCounterBumped { value, .. } => {
- demo_counter = demo_counter.max(value);
- }
Event::VoteRecorded {
ts,
a,
@@ -74,30 +71,20 @@ impl AppState {
}
}
+
… preview truncated; 6,096 characters omittedB — 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))))
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.