Side B fixes a real, well-diagnosed correctness bug in the core ranking algorithm (bipartite Markov chain non-convergence for star topologies), grounds the fix in the actual Rank Centrality paper, and adds targeted regression tests (Rust unit test plus Clojure end-to-end fixtures) that verify the fix. Side A is a large, mostly speculative new URL-canonicalization subsystem with extensive self-testing, but it's net-new architecture rather than a fix to an existing defect, making its lasting necessity less certain than B's precise, well-justified bugfix.
constitution · epochs · watch · epoch 3
c_9bced108c8aa (tommy-mor) vs c_48aeaf9b52c3 (tommy-mor)
download prompt · raw event · cmp_a4e10fceae4abc
council reasoning
A adds a full URL canonicalization subsystem (DFA graph, builder with link validation, parse/normalize/tracking-strip, generic fallback) plus broad regression coverage for Reddit/YouTube equivalence and breadcrumbs. B is a critical but localized correctness fix in Rank Centrality (degree-based d_max) with solid star/cycle fixtures; high leverage, yet narrower lasting surface than A’s new architecture.
Side A introduces an entire URL canonicalization subsystem: a graph-based traversal engine, parser, graph builder with validation, generic fallback behavior, Reddit/YouTube canonicalization, breadcrumbs, and extensive unit/integration tests. Side B is a valuable correctness fix that changes the Rank Centrality implementation from weight-sum normalization to degree-based d_max and adds regressions for the oscillation bug, but its scope is a targeted algorithm correction rather than the addition of a substantial new project capability.
sides
A — c_9bced108c8aa (tommy-mor)
message
[15e1037a] url stuff
diff preview
diff --git a/server/src/url_rules/graph.rs b/server/src/url_rules/graph.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f7ac0f9a551a1727cb2f9294778c283b9885b147
--- /dev/null
+++ b/server/src/url_rules/graph.rs
@@ -0,0 +1,831 @@
+//! Semantic URL graph: DFA traversal on host + path, query in context, generic fallback.
+
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+use url::Url;
+
+use super::graph_builder::GraphBuilder;
+use super::parse::{normalize_match_host, strip_tracking_query, UrlParts};
+
+#[derive(Debug, Clone, Default)]
+pub struct Context {
+ pub vars: HashMap<String, String>,
+ pub query: HashMap<String, String>,
+}
+
+pub type CanonicalFn = fn(&Context) -> Option<String>;
+
+#[derive(Clone, Copy)]
+pub enum EdgePattern {
+ Literal(&'static str),
+ Variable(&'static str),
+ /// Absorb any trailing segment without leaving this node (e.g. post title slug).
+ AbsorbAny,
+ /// Absorb segment when `cond(seg)` (e.g. subreddit listing suffix).
+ AbsorbIf(fn(&str) -> bool),
+}
+
+pub struct Edge {
+ pub pattern: EdgePattern,
+ pub target: &'static str,
+}
+
+pub struct Node {
+ pub edges: Vec<Edge>,
+ pub canonical: CanonicalFn,
+ pub parent: Option<&'static str>,
+}
+
+impl Node {
+ pub(crate) fn empty() -> Self {
+ Self {
+ edges: Vec::new(),
+ canonical: |_| None,
+ parent: None,
+ }
+ }
+}
+
+pub struct Graph {
+ pub nodes: HashMap<&'static str, Node>,
+}
+
+static GRAPH: OnceLock<Graph> = OnceLock::new();
+
+pub fn graph() -> &'static Graph {
+ GRAPH.get_or_init(build_graph)
+}
+
+impl Graph {
+ pub fn resolve_canonical(&self, parts: &UrlParts) -> Option<String> {
+ let mut query = parts.query.clone();
+ strip_tracking_query(&mut query);
+ let mut ctx = Context {
+ vars: HashMap::new(),
+ query,
+ };
+
+ if let Some(node_id) = self.traverse(parts, &mut ctx) {
+ if let Some(canon) = (self.nodes.get(node_id)?.canonical)(&ctx) {
+ return Some(canon);
+ }
+ }
+ Some(generic_canonical(parts))
+ }
+
+ pub fn breadcrumbs(&self, parts: &UrlParts) -> Vec<String> {
+ let mut query = parts.query.clone();
+ strip_tracking_query(&mut query);
+ let mut ctx = Context {
+ vars: HashMap::new(),
+ query,
+ };
+
+ if let Some(mut node_id) = self.traverse(parts, &mut ctx) {
+ let mut paths = Vec::new();
+ loop {
+ let node = match self.nodes.get(node_id) {
+ Some(n) => n,
+ None => break,
+ };
+ if let Some(url) = (node.canonical)(&ctx) {
+ if paths.last() != Some(&url) {
+ paths.push(url);
+ }
+ }
+ match node.parent {
+ Some(p) => node_id = p,
+ None => break,
+ }
+ }
+ paths.reverse();
+ if !paths.is_empty() {
+ return paths;
+ }
+ }
+ generic_breadcrumbs(parts)
+ }
+
+ fn traverse(&self, parts: &UrlParts, ctx: &mut Context) -> Option<&'static str> {
+ let host = parts.match_host();
+ let mut node_id = match host.as_str() {
+ "reddit.com" => "reddit_root",
+ "youtube.com" => "youtube_root",
+ "youtu.be" => "youtu_be_entry",
+ _ => return None,
+ };
+
+ let segs: Vec<&str> = parts.path_segments.iter().map(String::as_str).collect();
+ let mut i = 0;
+ while i < segs.len() {
+ let seg = segs[i];
+ match self.follow_edge(node_id, seg, ctx) {
+ Ok(next) => {
+ node_id = next;
+ i += 1;
+ }
+ Err(()) => {
+ if self.try_absorb(node_id, seg) {
+ i += 1;
+ continue;
+ }
+ return None;
+ }
+ }
+ }
+ Some(node_id)
+ }
+
+ fn follow_edge(
+ &self,
+ node_id: &'static str,
+ seg: &str,
+ ctx: &mut Context,
+ ) -> Result<&'static str, ()> {
+ let node = self.nodes.get(node_id).ok_or(())?;
+ for edge in &node.edges {
+ match edge.pattern {
+ EdgePattern::Literal(lit) if lit == seg => return Ok(edge.target),
+ EdgePattern::Variable(name) => {
+ ctx.vars.insert(name.to_string(), seg.to_string());
+ return Ok(edge.target);
+ }
+ EdgePattern::AbsorbAny
+ | EdgePattern::AbsorbIf(_)
+ | EdgePattern::Literal(_)
+ | EdgePattern::Variable(_) => {}
+ }
+ }
+ Err(())
+ }
+
+ fn try_absorb(&self, node_id: &'static str, seg: &str) -> bool {
+ let node = match self.nodes.get(node_id) {
+ Some(n) => n,
+ None => return false,
+ };
+ for edge in &node.edges {
+ match edge.pattern {
+ EdgePattern::AbsorbAny => return true,
+ EdgePattern::AbsorbIf(cond) if cond(seg) => return true,
+ EdgePattern::AbsorbIf(_) | EdgePattern::Literal(_) | EdgePattern::Variable(_) => {}
+ }
+ }
+ false
+ }
+
+ /// Test hook: terminal graph node and captured context after traversal.
+ #[cfg(test)]
+ pub fn traverse_terminal(&self, parts: &UrlParts) -> Option<(&'static str, Context)> {
+ let mut query = parts.query.clone();
+ strip_tracking_query(&mut query);
+ let mut ctx = Context {
+ vars: HashMap::new(),
+ query,
+ };
+ let node = self.traverse(parts, &mut ctx)?;
+ Some((node, ctx))
+ }
+}
+
+fn is_reddit_listing_suffix(seg: &str) -> bool {
+ matches!(seg, "hot" | "top" | "new" | "rising" | "controversial")
+}
+
+/// Percent-encode a path or query fragment so `&`, `?`, etc. cannot break URL structure.
+fn enc(s: &str) -> String {
+ urlencoding::encode(s).into_owned()
+}
+
+// --- Canonical formatters ---
+
+fn canon_reddit_root(_: &Context) -> Option<String> {
+ Some("https://reddit.com".to_string())
+}
+
+fn canon_reddit_r_hub(_: &Context) -> Option<String> {
+ Some("https://reddit.com/r".to_string())
+}
+
+fn canon_reddit_subreddit(ctx: &Context) -> Option<String> {
+ let sub = ctx.vars.get("subreddit")?;
+ Some(format!(
+ "https://reddit.com/r/{}",
+ enc(&sub.to_ascii_lowercase())
+ ))
+}
+
+fn canon_reddit_post(ctx: &Context) -> Option<String> {
+ let sub = ctx.vars.get("subreddit")?.to_ascii_lowercase();
+ let id = ctx.vars.get("post_id")?;
+ Some(format!(
+ "https://reddit.com/r/{}/comments/{}",
+ enc(&sub),
+ enc(id)
+ ))
+}
+
+fn canon_youtube_root(_: &Context) -> Option<String> {
+ Some("https://youtube.com".to_string())
+}
+
+fn canon_youtube_watch(ctx: &Context) -> Option<String> {
+ let v = ctx
+ .query
+ .get("v")
+ .or_else(|| ctx.vars.get("video_id"))?;
+ Some(format!("https://youtube.com/watch?v={}", enc(v)))
+}
+
+fn canon_youtu_be(ctx: &Context) -> Option<String> {
+ let v = ctx.vars.get("vid_id")?;
+ Some(format!("https://youtube.com/watch?v={}", enc(v)))
+}
+
+pub fn build_graph() -> Graph {
+ GraphBuilder::new()
+ .node("reddit_root")
+ .canonical(canon_reddit_root)
+ .edge(EdgePattern::Literal("r"), "reddit_r_hub")
+ .node("reddit_r_hub")
+ .parent("reddit_root")
+ .canonical(canon_reddit_r_hub)
+ .edge(EdgePattern::Variable("subreddit"), "reddit_subreddit")
+ .node("reddit_subreddit")
+ .parent("reddit_r_hub")
+ .canonical(canon_reddit_subreddit)
+ .edge(
+ EdgePattern::AbsorbIf(is_reddit_listing_suffix),
+ "reddit_subreddit",
+ )
+ .edge(EdgePattern::Literal("comments"), "reddit_comments_gate")
+ .node("reddit_comments_gate")
+ .parent("reddit_subreddit")
+ .canonical(canon_reddit_subreddit)
+ .edge(EdgePattern::Variable("post_id"), "reddit_post")
+ .node("reddit_post")
+ .parent("reddit_subreddit")
+ .canonical(canon_reddit_post)
+ .edge(EdgePattern::AbsorbAny, "reddit_post")
+ .node("youtube_root")
+ .canonical(canon_youtube_root)
+ .edge(EdgePattern::Literal("watch"), "youtube_watch")
+ .edge(EdgePattern::Literal("shorts"), "youtube_shorts_gate")
+ .node("youtube_watch")
+ .parent("youtube_root")
+ .canonical(canon_youtube_watch)
+ .node("youtube_shorts_gate")
+ .parent("youtube_root")
+ .canonical(canon_youtube_root)
+ .edge(EdgePattern::Variable("video_id"), "youtube_watch")
+ .node("youtu_be_entry")
+ .canonical(canon_youtube_root)
+ .edge(EdgePattern::Variable("vid_id"), "youtu_be_video")
+ .node("youtu_be_video")
+ .parent("youtube_root")
+ .canonical(canon_youtu_be)
+ .build()
+}
+
+// --- Generic internet fallback ---
+
+pub fn generic_canonical(parts: &UrlParts) -> String {
+ let host = normalize_match_host(&parts.host);
+ let path_segments: Vec<String> = parts.path_segments.clone();
+ let mut query = parts.query.clone();
+ strip_tracking_query(&mut query);
+
+ let mut url = if path_segments.is_empty() {
+ Url::parse(&format!("https://{host}"))
+ .unwrap_or_else(|_| Url::parse("https://invalid").unwrap())
+ } else {
+ let path = format!("/{}", path_segments.join("/"));
+ Url::parse(&format!("https://{host}{path}"))
+ .unwrap_or_else(|_| Url::parse("https://invalid").unwrap())
+ };
+
+ if !query.is_empty() {
+ let mut pairs: Vec<_> = query.iter().collect();
+ pairs.sort_by(|a, b| a.0.cmp(b.0));
+ url.query_pairs_mut().clear();
+ for (k, v) in pairs {
+ url.query_pairs_mut().append_pair(k, v);
+ }
+ }
+
+ let mut s = url.to_string();
+ if path_segments.is_empty() {
+ s = s.trim_end_matches('/').to_string();
+ }
+ s
+}
+
+pub fn generic_breadcrumbs(parts: &UrlParts) -> Vec<String> {
+ let host = normalize_match_host(&parts.host);
+ let n = parts.path_segments.len();
+ let mut out = Vec::new();
+
+ let base = generic_canonical(&UrlParts {
+ scheme: "https".to_string(),
+ host: host.clone(),
+ path_segments: vec![],
+ query: HashMap::new(),
+ });
+ out.push(base);
+
+ for i in 0..n {
+ let segs: Vec<String> = parts.path_segments[..=i].to_vec();
+ let url = generic_canonical(&UrlParts {
+ scheme: "https".to_string(),
+ host: host.clone(),
+ path_segments: segs,
+ query: HashMap::new(),
+ });
+ if out.last() != Some(&url) {
+ out.push(url);
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::url_rules::parse::test_parts;
+
+ fn g() -> &'static Graph {
+ graph()
+ }
+
+ fn canon(parts: &UrlParts) -> String {
+ g().resolve_canonical(parts).unwrap()
+ }
+
+ fn crumbs(parts: &UrlParts) -> Vec<String> {
+ g().breadcrumbs(parts)
+ }
+
+ fn terminal(parts: &UrlParts) -> Option<&'static str> {
+ g().traverse_terminal(parts).map(|(n, _)| n)
+ }
+
+ fn vars(parts: &UrlParts) -> HashMap<String, String> {
+ g().traverse_terminal(parts)
+ .map(|(_, c)| c.vars)
+ .unwrap_or_default()
+ }
+
+ #[test]
+ fn youtu_be_malicious_segment_encoded_not_injected() {
+ let p = test_parts("youtu.be", &["abc&t=1"], &[]);
+ assert_eq!(canon(&p
… preview truncated; 29,502 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.