Side B fixes a real structural bug in the ranking system (short-circuiting on single contributor instead of per-commit comparison), with corresponding test updates and UI surfacing, giving lasting improvement to the project's core fairness logic. Side A is a large but reasonable simplification/replacement of a fragile autocomplete parser with a simpler paste-and-go UX, which is valuable but more narrowly scoped and mostly deletion of complex, over-engineered code plus a UI feature swap.
constitution · epochs · watch · epoch 3
c_8dc1a8119370 (tommy-mor) vs c_fbeec5c4ad18 (tommy-mor)
download prompt · raw event · cmp_fad652158358fb
council reasoning
B fixes core ownership mechanics: rank_commits no longer short-circuits on a single contributor, pairwise-ranks every eligible commit, rolls scores into contributor_ranking, and exposes per-commit rankings on epoch pages—with tests for the multi-commit same-author case. A’s paste-and-go rewrite correctly deletes an unreliable ~1.8k-line keystroke graph and race-prone client path, but that is a UI simplification of one feature, not a change to how lasting value is measured or paid out.
Side B changes the core ranking algorithm from contributor-level to commit-level by pairwise-ranking every eligible commit, rolling scores back up to contributors, updating evidence records, UI pages, and tests to reflect commit rankings. Side A mostly replaces an interactive autocomplete/parser graph with a much simpler paste-and-go URL parser and redirect, deleting substantial functionality in favor of a narrower workflow, even though it simplifies the implementation.
sides
A — c_8dc1a8119370 (tommy-mor)
message
[529cc941] Replace autocomplete parser with paste-and-go navigate. The keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/AGENTS.md b/AGENTS.md
index 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
- **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.
- **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).
- **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"chromium\" \"--with-deps\"]))"`. The browser binary is cached under `~/.cache/ms-playwright`.
### Commands (see also `TEST.sh`)
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use crate::{
html::{js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
- parser_render::parser_panel_morph,
+ parser_render::navigate_panel,
state::AppState,
ui_action::{parse_html_ui_from_form, HtmlUiAction},
};
@@ -57,18 +57,23 @@ pub async fn post_ui_html(
.morph_selector("#ranking-panel", panel)
.into_response()
}
- HtmlUiAction::ParseQuery { query } => {
- let action = parse_reddit_url(&query);
- let panel = parser_panel_morph(&query, &action);
- let mut js = JsBuilder::new().morph_selector("#parser-panel", panel);
- if let Some(comp) = action.primary_completion() {
- js = js.raw(&format!(
- "var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}",
- js_string_literal(comp)
- ));
+ HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
+ Ok(subreddit) => {
+ let dest = format!("/?sub={subreddit}");
+ JsBuilder::new()
+ .raw(&format!(
+ "window.location.href={};",
+ js_string_literal(&dest)
+ ))
+ .into_response()
}
- js.into_response()
- }
+ Err(message) => {
+ let panel = navigate_panel(&query, Some(&message));
+ JsBuilder::new()
+ .morph_selector("#parser-panel", panel)
+ .into_response()
+ }
+ },
}
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -11,8 +11,7 @@ use serde::Deserialize;
use crate::{
form_template::template_json_compact,
- parser_action::ParserAction,
- parser_render::parser_panel,
+ parser_render::navigate_panel,
ranking::{top_bottom, RankedItem},
reducer::GroupState,
state::{normalize_scope, AppState},
@@ -336,10 +335,9 @@ pub async fn home(
let empty = GroupState::new();
let group = groups.get(&scope).unwrap_or(&empty);
- let empty_action = ParserAction::suggest(String::new(), None);
let body = html! {
h1 { "sorter2" }
- (parser_panel("", &empty_action))
+ (navigate_panel("", None))
(vote_panel(&scope))
(ranking_panel(&scope, group))
};
diff --git a/server/src/lib.rs b/server/src/lib.rs
index fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,6 @@ pub mod events;
pub mod form_template;
pub mod html;
pub mod parser;
-pub mod parser_action;
pub mod parser_render;
pub mod path_types;
pub mod ranking;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,1811 +1,87 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
+//! Extract a subreddit name from a pasted Reddit URL or path.
-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
- /// Matches exact literal string
- Literal(&'static str),
-
- /// Matches any prefix of a string and suggests the full string
- /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
- PrefixOf(&'static str),
-
- /// Captures a variable segment (e.g., subreddit name, username)
- Variable(&'static str),
-
- /// Matches any string (wildcard)
- Any,
-}
-
-impl EdgePattern {
- /// Try to match this pattern against input, return (consumed_chars, captured_value)
- fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
- match self {
- EdgePattern::Literal(lit) => {
- if input.starts_with(lit) {
- Some((lit.len(), None))
- } else {
- None
- }
- }
- EdgePattern::PrefixOf(target) => {
- // Check if input is a prefix of target
- if target.starts_with(input) && !input.is_empty() {
- // It's a valid prefix
- Some((input.len(), None))
- } else if input.starts_with(target) {
- // Full match
- Some((target.len(), None))
- } else {
- None
- }
- }
- EdgePattern::Variable(var_name) => {
- // Consume until next '/' or end of string
- let end = input.find('/').unwrap_or(input.len());
- if end > 0 {
- let captured = input[..end].to_string();
- // Validate based on variable type
- if is_valid_variable(var_name, &captured) {
- Some((end, Some(captured)))
- } else {
- None
- }
- } else {
- None
- }
- }
- EdgePattern::Any => {
- // Match everything until next '/' or end
- let end = input.find('/').unwrap_or(input.len());
- if end > 0 {
- Some((end, Some(input[..end].to_string())))
- } else {
- None
- }
- }
- }
- }
-
- /// Get the completion suggestion for this pattern
- fn completion(&self, partial: &str) -> Option<String> {
- match self {
- EdgePattern::PrefixOf(target) => {
- if target.starts_with(partial) && partial != *target {
- Some(target.to_string())
- } else {
- None
- }
- }
- _ => None,
- }
+pub fn parse_reddit_url(query: &str) -> Result<String, String> {
+ let q = query.trim();
+ if q.is_empty() {
+ return Err("Paste a Reddit URL or r/subreddit path".into());
}
-}
-/// Edge in the graph
-pub struct Edge {
- pattern: EdgePattern,
- target: NodeId,
- /// Optional description for autocomplete
- description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync>;
-
-/// Node in the graph
-pub struct Node {
- #[allow(dead_code)]
- id: NodeId,
- edges: Vec<Edge>,
- handler: Option<Handler>,
-}
-
-/// The composable parser graph (immutable after `build`).
-pub struct Graph {
- nodes: HashMap<NodeId, Node>,
- root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
- nodes: HashMap<NodeId, Node>,
- current_node: Option<NodeId>,
- root: NodeId,
-}
-
-impl GraphBuilder {
- pub fn new() -> Self {
- let mut nodes = HashMap::new();
- nodes.insert(
- "root",
- Node {
- id: "root",
- edges: Vec::new(),
- handler: None,
- },
- );
-
- GraphBuilder {
- nodes,
- current_node: Some("root"),
- root: "root",
- }
+ if let Some(sub) = subreddit_after_prefix(q, "r/") {
+ return Ok(sub);
}
- /// Select a node to add edges to
- pub fn at(mut self, node_id: NodeId) -> Self {
- self.nodes.entry(node_id).or_insert_with(|| Node {
- id: node_id,
- edges: Vec::new(),
- handler: None,
- });
- self.current_node = Some(node_id);
- self
+ if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
+ return Ok(sub);
}
-
- /// Add an edge from the current node
- pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
- self.edge_with_desc(pattern, target, None)
- }
-
- /// Add an edge with description
- pub fn edge_with_desc(
- mut self,
- pattern: EdgePattern,
- target: NodeId,
- desc: Option<&'static str>,
- ) -> Self {
- let current = self.current_node.expect("No current node selected");
-
- self.nodes.entry(target).or_insert_with(|| Node {
- id: target,
- edges: Vec::new(),
- handler: None,
- });
-
- if let Some(node) = self.nodes.get_mut(current) {
- node.edges.push(Edge {
- pattern,
- target,
- description: desc,
- });
- }
- self
- }
-
- /// Set handler for current node
- pub fn handler<F>(mut self, handler: F) -> Self
- where
- F: Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync + 'static,
- {
- let current = self.current_node.expect("No current node selected");
- if let Some(node) = self.nodes.get_mut(current) {
- node.handler = Some(Box::new(handler));
- }
- self
- }
-
- /// Build the final graph
- pub fn build(self) -> Graph {
- Graph {
- nodes: self.nodes,
- root: self.root,
- }
- }
+ Err("Could not find a subreddit in that URL".into())
}
-// --- Parser Implementation ---
-
-impl Graph {
- pub fn parse(&self, input: &str) -> ParserAction {
- let normalized = input.trim().to_lowercase();
- let mut state = ParserState {
- input: &normalized,
- cursor: 0,
- current_node_id: self.root,
- context: HashMap::new(),
- original_query: input.to_string(),
- current_prefix: String::new(),
- };
-
- self.parse_recursive(&mut state)
- }
-
- fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {
- let node = self
- .nodes
- .get(state.current_node_id)
- .expect("Node not found in graph");
-
- // If we've consumed all input, check for handler or suggestions
- if state.cursor >= state.input.len() {
- if let Some(handler) = &node.han
… preview truncated; 92,533 characters omittedB — c_fbeec5c4ad18 (tommy-mor)
message
[c7ef287e] Rank every eligible commit with the LLM council. Stop short-circuiting on a single contributor; pairwise-sort commits, roll scores up for emission payouts, and surface commit rankings on epoch pages. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/constitution.py b/constitution.py
index 26ba130e885e0e69fb7874ca5c3f07f42100a150..71fc46b4c9a4d7a9ea7bf319860b0db1acc4a673 100644
--- a/constitution.py
+++ b/constitution.py
@@ -503,20 +503,22 @@ def _epochs_in_ledger() -> list[int]:
def build_pairwise_prompt(side_a: dict, side_b: dict) -> str:
- return f"""You are ranking contributions to an open source project.
-Compare these two sides (each may be one or more commits). Decide which side contributed more.
+ return f"""You are ranking individual git commits to an open source project.
+Compare these two commits. Decide which commit contributed more.
Return ONLY a JSON object: {{"winner": "A" or "B", "ratio": "N:M", "explanation": "..."}}
-Side A — commit messages:
+Side A — contributor: {side_a.get('contributor', '?')}
+Side A — commit message:
{side_a['message']}
-Side A — unified diffs (full patches):
+Side A — unified diff (full patch):
{side_a['diff']}
-Side B — commit messages:
+Side B — contributor: {side_b.get('contributor', '?')}
+Side B — commit message:
{side_b['message']}
-Side B — unified diffs (full patches):
+Side B — unified diff (full patch):
{side_b['diff']}"""
@@ -1518,16 +1520,28 @@ async def broadcast_js(js: str):
await queue.put(js)
-def _author_side_for_llm(author: str, author_commits: dict) -> dict:
- cs = author_commits[author]
+def _commit_side_for_llm(row: dict) -> dict:
+ oid = row["oid"]
+ short = oid.split(":", 1)[1][:8] if ":" in oid else oid[:8]
return {
- "message": "\n".join(f"[{c['sha']}] {c['message']}" for c in cs),
- "diff": "\n\n".join(f"=== {c['sha']} ===\n{c['diff']}" for c in cs),
- "commit_ids": [c["commit_id"] for c in cs],
- "contributor": author,
+ "message": f"[{short}] {row['message']}",
+ "diff": row["patch"] or "",
+ "commit_id": commit_id_for_oid(oid),
+ "contributor": row["contributor"],
+ "oid": oid,
}
+def _rollup_contributor_scores(
+ ordered: list[dict], commit_scores: list[Decimal]
+) -> dict[str, Decimal]:
+ totals: dict[str, Decimal] = {}
+ for row, score in zip(ordered, commit_scores):
+ contributor = row["contributor"]
+ totals[contributor] = totals.get(contributor, Decimal("0")) + score
+ return totals
+
+
def _find_judgment(comparison_id: str, model_id: str) -> dict | None:
for e in evidence_by_kind("llm.judgment"):
p = e.payload
@@ -1546,101 +1560,106 @@ def _find_ranking_models(ranking_run_id: str) -> list[str] | None:
async def rank_commits(commits: list[dict], *, epoch: int = -1):
+ """Pairwise-rank every eligible commit; roll scores up to contributors."""
if not commits:
return {}, [], {"ranking_run_id": "", "ranking_event_id": ""}
- commit_ids = sorted(commit_id_for_oid(row["oid"]) for row in commits)
+ ordered = sorted(commits, key=lambda r: r["oid"])
+ commit_ids = [commit_id_for_oid(row["oid"]) for row in ordered]
ranking_run_id = _content_id("rank", {
"epoch": epoch,
- "commit_ids": commit_ids,
+ "commit_ids": sorted(commit_ids),
})
- contributors = sorted(set(c["contributor"] for c in commits))
+ contributors = sorted({c["contributor"] for c in ordered})
- if len(contributors) == 1:
+ # Nothing to compare: a single commit (not a single contributor).
+ if len(ordered) == 1:
await append_evidence(epoch, "ranking.started", {
"ranking_run_id": ranking_run_id,
"commit_ids": commit_ids,
"contributors": contributors,
"models": [],
- "summary": f"ranking epoch {epoch}: single contributor",
+ "summary": f"ranking epoch {epoch}: single commit",
})
- ranking = {contributors[0]: Decimal("1")}
+ commit_ranking = {commit_ids[0]: "1"}
+ contributor_ranking = {ordered[0]["contributor"]: Decimal("1")}
completed = await append_evidence(epoch, "ranking.completed", {
"ranking_run_id": ranking_run_id,
"models": [],
- "ranking": {contributors[0]: "1"},
+ "commit_ranking": commit_ranking,
+ "contributor_ranking": {ordered[0]["contributor"]: "1"},
+ "ranking": {ordered[0]["contributor"]: "1"},
"judgment_ids": [],
- "summary": f"Only {contributors[0]} is eligible; rank is 1.0",
+ "summary": f"Only one eligible commit; {ordered[0]['contributor']} rank 1.0",
})
await broadcast_audit(
"ranking",
- f"Only {contributors[0]} is eligible; rank is 1.0",
+ f"Only one eligible commit; {ordered[0]['contributor']} rank 1.0",
progress=90,
phase="finalizing",
evidence_event_id=completed.event_id,
evidence_url=_evidence_url("event", completed.event_id),
links={"epoch": _evidence_url("epoch", str(epoch))},
)
- return ranking, [], {
+ return contributor_ranking, [], {
"ranking_run_id": ranking_run_id,
"ranking_event_id": completed.event_id,
}
if not (OPENROUTER_API_KEY or "").strip():
raise RuntimeError(
- "OPENROUTER_API_KEY is required when multiple contributors need ranking"
+ "OPENROUTER_API_KEY is required when multiple commits need ranking"
)
models = _find_ranking_models(ranking_run_id)
if models is None:
models = await fetch_top_models(n=3)
if not models:
- raise RuntimeError("no council models available for contributor ranking")
+ raise RuntimeError("no council models available for commit ranking")
await append_evidence(epoch, "ranking.started", {
"ranking_run_id": ranking_run_id,
"commit_ids": commit_ids,
"contributors": contributors,
"models": models,
- "summary": f"Council selected: {', '.join(models)}",
+ "summary": (
+ f"Council selected: {', '.join(models)} — "
+ f"{len(ordered)} commits"
+ ),
})
await broadcast_audit(
"council",
- f"Council selected: {', '.join(models)}",
+ f"Council selected: {', '.join(models)} — ranking {len(ordered)} commits",
progress=35,
phase="ranking",
)
await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
- ["div.log-council", f"Council: {', '.join(models)} — {len(commits)} commits"]
+ ["div.log-council",
+ f"Council: {', '.join(models)} — {len(ordered)} commits"]
]))
- authors = contributors
- author_commits = {a: [] for a in authors}
- for row in sorted(commits, key=lambda r: r["oid"]):
- author_commits[row["contributor"]].append({
- "message": row["message"],
- "sha": row["oid"].split(":", 1)[1][:8],
- "diff": row["patch"],
- "commit_id": commit_id_for_oid(row["oid"]),
- })
-
+ sides = [_commit_side_for_llm(row) for row in ordered]
judgment_ids: list[str] = []
async def compare_fn(i, j):
- a1, a2 = authors[i], authors[j]
- side_a = _author_side_for_llm(a1, author_commits)
- side_b = _author_side_for_llm(a2, author_commits)
+ side_a, side_b = sides[i], sides[j]
+ label_a = f"{side_a['commit_id'][:16]} ({side_a['contributor']})"
+ label_b = f"{side_b['commit_id'][:16]} ({side_b['contributor']})"
prompt = build_pairwise_prompt(side_a, side_b)
comparison_material = {
"ranking_run_id": ranking_run_id,
"side_a": {
- "contributor": a1,
- "commit_ids": side_a["commit_ids"],
+ "contributor": side_a["contributor"],
+ "commit_id": side_a["commit_id"],
+ "commit_ids": [side_a["commit_id"]],
+ "oid": side_a["oid"],
"message": _bytes_blob(side_a["message"]),
"diff": _bytes_blob(side_a["diff"]),
},
"side_b": {
- "contributor": a2,
- "commit_ids": side_b["commit_ids"],
+ "contributor": side_b["contributor"],
+ "commit_id": side_b["commit_id"],
+ "commit_ids": [side_b["commit_id"]],
+ "oid": side_b["oid"],
"message": _bytes_blob(side_b["message"]),
"diff": _bytes_blob(side_b["diff"]),
},
@@ -1650,22 +1669,24 @@ async def rank_commits(commits: list[dict], *, epoch: int = -1):
comparison_material = {
**comparison_material,
"comparison_id": comparison_id,
- "summary": f"Comparing {a1} with {a2}",
+ "summary": f"Comparing {label_a} with {label_b}",
}
cmp_ev = await append_evidence(epoch, "comparison.input", comparison_material)
await broadcast_audit(
"comparison",
- f"Comparing {a1} with {a2}",
+ f"Comparing commits {label_a} vs {label_b}",
phase="ranking",
evidence_event_id=cmp_ev.event_id,
evidence_url=_evidence_url("comparison", comparison_id),
links={
"comparison": _evidence_url("comparison", comparison_id),
+ "commit_a": _evidence_url("commit", side_a["commit_id"]),
+ "commit_b": _evidence_url("commit", side_b["commit_id"]),
"epoch": _evidence_url("epoch", str(epoch)),
},
)
await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][
- ["div#emission-status", f"Comparing {a1} vs {a2}…"]
+ ["div#emission-status", f"Comparing {label_a} vs {label_b}…"]
]))
results = []
for model in models:
@@ -1697,9 +1718,15 @@ async def rank_commits(commits: list[dict], *, epoch: int = -1):
jud_id = (existing or _find_judgment(comparison_id, model) or {}).get(
"judgment_id"
)
+ win_label = (
+ f"{sides[w]['commit_id'][:16]} ({sides[w]['contributor']})"
+ )
+ lose_label = (
+ f"{sides[l]['commit_id'][:16]} ({sides[l]['contributor']})"
+ )
await broadcast_audit(
"vote",
- f"{model}: {authors[w]} over {authors[l]} ({result['ratio']})",
+ f"{model}: {win_label} over {lose_label} ({result['ratio']})",
phase="ranking",
evidence_url=(
_evidence_url("judgment", jud_id) if jud_id else None
@@ -1714,8 +1741,8 @@ async def rank_commits(commits: list[dict], *, epoch: int = -1):
await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
["div.log-vote",
["span.model", model], " — ",
- ["span.winner", authors[w]], f" beat ",
- ["span.loser", authors[l]], f" ({result['ratio']}) ",
+ ["span.winner", win_label], f" beat ",
+ ["span.loser", lose_label], f" ({result['ratio']}) ",
["span.explanation", result["explanation"]],
]
]))
@@ -1745,24 +1772,46 @@ async def rank_commits(commits: list[dict], *, epoch: int = -1):
["div#emission-status", label]
]))
- pairs = await pairwise_rank(len(authors), compare_fn, progress_fn)
+ pairs = await pairwise_rank(len(ordered), compare_fn, progress_fn)
if not pairs:
- ranking = {authors[0]: Decimal("1")} if authors else {}
+ commit_score_list = [Decimal("1")]
else:
scores = rank_centrality(pairs)
- ranking = {authors[i]: Decimal(str(scores[i])) for i in range(len(authors))}
… preview truncated; 12,453 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.