constitution · epochs · watch · epoch 3

comparison

c_3f420a1f5aa1 (tommy-mor) vs c_abd68b5e771a (tommy-mor)

download prompt · raw event · cmp_cb22f69dd79b35

council reasoning

~anthropic/claude-sonnet-latest · winner B · 60:40 · permalink

B is a small, precise bugfix that removes ghost items and false voted_pairs entries from the reducer's core ranking logic, correcting a real correctness issue with accompanying test verification. A is a larger feature (theme switcher rework + room-scoped URL helpers) that adds value but is more speculative/UI-focused, mixes several concerns, and lacks the surgical correctness guarantee of B's fix to core data integrity.

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

B fixes a real reducer integrity bug by dropping zero-ratio votes before ensure_item/voted_pairs side effects, eliminating ghost items and false “already voted” state, with a matching test update. A is largely theme UX (cookie/select plumbing threaded through many handlers) plus useful but secondary private-room URL wire helpers—valuable product surface, yet more diffuse and less foundational than the ranking-state correctness fix.

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

Side B fixes a correctness bug in the reducer by moving the zero-ratio guard before `ensure_item` and `voted_pairs.insert`, preventing ghost items and incorrectly recorded voted pairs, and updates the test to verify the corrected behavior. Side A adds broad theme persistence and room-aware URL handling across many files, but it is largely feature work and plumbing rather than a targeted correctness fix with the same lasting impact on core data integrity.

sides

A — c_3f420a1f5aa1 (tommy-mor)

message

[2fe70b0e] themes

diff preview

diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 559db65de14c6157690ffbf18eca0cf65b0a5202..b3631b06153d52f88348fef927e7a024b7b85ad6 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -1,7 +1,7 @@
 use axum::{
     body::Body,
     extract::{Path, Query, State},
-    http::{header, HeaderMap, HeaderValue, StatusCode},
+    http::{header, HeaderMap, HeaderValue, StatusCode, Uri},
     response::{IntoResponse, Redirect, Response},
     Form, Json,
 };
@@ -17,7 +17,7 @@ use crate::{
     events::{Event, GrantAdded, TokenIssued, UserRegistered},
     html::{
         auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
-        choose_username_page, JsBuilder,
+        choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,
     },
     identity::{parse_agent, parse_username},
     reducer::ReducerState,
@@ -48,15 +48,17 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {
         .into_response()
 }
 
-fn js_signed_in_fragment(bearer: &str) -> Response {
+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
     let mut response = JsBuilder::new()
         .id("choose-username-form")
         .morph_inner(auth_signed_in_fragment())
         .redirect("/auth/complete")
         .into_response();
-    response
-        .headers_mut()
-        .insert(header::SET_COOKIE, session_cookie_header_value(bearer));
+    let headers = response.headers_mut();
+    headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
+    if let Some(theme) = theme_cookie_header_from_jar(jar) {
+        headers.append(header::SET_COOKIE, theme);
+    }
     response
 }
 
@@ -69,13 +71,18 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce
     verify_token(reduced, c.value()).ok()
 }
 
-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str) -> Response {
-    Response::builder()
+fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
+    let mut res = Response::builder()
         .status(StatusCode::TEMPORARY_REDIRECT)
         .header(header::LOCATION, format!("{public_url}{path_and_query}"))
-        .header(header::SET_COOKIE, session_cookie_header_value(bearer))
         .body(Body::empty())
-        .unwrap()
+        .unwrap();
+    let headers = res.headers_mut();
+    headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
+    if let Some(theme) = theme_cookie_header_from_jar(jar) {
+        headers.append(header::SET_COOKIE, theme);
+    }
+    res
 }
 
 async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
@@ -286,7 +293,11 @@ pub struct AuthCallbackQuery {
     pub state: String,
 }
 
-pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_auth_callback(
+    Query(q): Query<AuthCallbackQuery>,
+    State(state): State<AppState>,
+    jar: CookieJar,
+) -> impl IntoResponse {
     let sessions = pending_sessions(&state);
     {
         let sessions_read = sessions.read().await;
@@ -365,7 +376,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
             }
             let cookie_bearer = bearer.clone();
             s.complete = Some((username, bearer));
-            return redirect_with_session_cookie(&public_url, "/", &cookie_bearer).into_response();
+            return redirect_with_session_cookie(&public_url, "/", &cookie_bearer, &jar).into_response();
         }
     }
 
@@ -378,14 +389,20 @@ pub struct ChooseUsernameQuery {
     pub error: Option<String>,
 }
 
-pub async fn get_choose_username(Query(q): Query<ChooseUsernameQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_choose_username(
+    Query(q): Query<ChooseUsernameQuery>,
+    State(state): State<AppState>,
+    jar: CookieJar,
+    uri: Uri,
+) -> impl IntoResponse {
     let sessions = pending_sessions(&state);
     let sessions_read = sessions.read().await;
     if !sessions_read.contains_key(&q.session) {
         return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
     }
     drop(sessions_read);
-    choose_username_page(&q.session, q.error.as_deref()).into_response()
+    let next = theme_next_from_uri(&uri);
+    choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response()
 }
 
 #[derive(Debug, Deserialize)]
@@ -396,6 +413,7 @@ pub struct ChooseUsernameForm {
 
 pub async fn post_choose_username(
     State(state): State<AppState>,
+    jar: CookieJar,
     Form(form): Form<ChooseUsernameForm>,
 ) -> impl IntoResponse {
     let canon_user = match parse_username(&form.username) {
@@ -477,7 +495,7 @@ pub async fn post_choose_username(
         s.complete = Some((canon_user.clone(), bearer.clone()));
     }
 
-    js_signed_in_fragment(&bearer).into_response()
+    js_signed_in_fragment(&bearer, &jar).into_response()
 }
 
 /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success.
@@ -569,8 +587,9 @@ pub async fn get_pending_session(
     .into_response()
 }
 
-pub async fn get_auth_complete() -> impl IntoResponse {
-    auth_complete_page()
+pub async fn get_auth_complete(jar: CookieJar, uri: Uri) -> impl IntoResponse {
+    let next = theme_next_from_uri(&uri);
+    auth_complete_page(theme_from_jar(&jar), &next).into_response()
 }
 
 pub async fn get_whoami(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index 81e2a55fa3abb8609b4099f91a989480336e11eb..9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -39,6 +39,55 @@ pub fn item_path_for_api(item: &str) -> String {
     }
 }
 
+/// Same as [`item_path_for_api`], but for private rooms ontology items are prefixed with
+/// `/r/{short}/{slug}` so the URL matches the web app (`/r/…/~/…` routes).
+pub fn item_path_for_api_in_room(item: &str, room_wire: &str) -> String {
+    let room = room_wire.trim();
+    if room.is_empty() || room == "public" {
+        return item_path_for_api(item);
+    }
+    let Some((short, slug)) = room.split_once('/') else {
+        return item_path_for_api(item);
+    };
+    if short.is_empty() || slug.is_empty() {
+        return item_path_for_api(item);
+    }
+    let Some(c) = CanonicalItemUrl::parse(item) else {
+        return item_path_for_api(item);
+    };
+    let root = CanonicalItemUrl::ontology_root();
+    let item_norm = c.as_str().trim_end_matches('/');
+    let root_norm = root.as_str().trim_end_matches('/');
+    if let Some(tail) = c.tilde_tail() {
+        return if tail.is_empty() {
+            format!("https://slug.social/r/{short}/{slug}/~")
+        } else {
+            format!("https://slug.social/r/{short}/{slug}/~/{}", tail)
+        };
+    }
+    if item_norm == root_norm {
+        return format!("https://slug.social/r/{short}/{slug}/~");
+    }
+    item_path_for_api(item)
+}
+
+/// Absolute thread URL for forum JSON (`/t/…` vs `/r/…/t/…`).
+pub fn forum_thread_web_url(room_wire: &str, thread_tag: &str) -> String {
+    let room = room_wire.trim();
+    let tag = thread_tag.trim().trim_start_matches('#');
+    if room.is_empty() || room == "public" {
+        format!("https://slug.social/t/{tag}")
+    } else if let Some((short, slug)) = room.split_once('/') {
+        if short.is_empty() || slug.is_empty() {
+            format!("https://slug.social/t/{tag}")
+        } else {
+            format!("https://slug.social/r/{short}/{slug}/t/{tag}")
+        }
+    } else {
+        format!("https://slug.social/t/{tag}")
+    }
+}
+
 /// Resolve an item path as a first-class canonical path.
 pub fn resolve_item(item: &str) -> Result<String, String> {
     let canonical = canonicalize_item(item);
@@ -188,3 +237,52 @@ pub fn vote_touches_path(a: &str, b: &str, parent_canon: &str) -> bool {
     let under = |item: &str| item == parent_canon || item.starts_with(&format!("{}/", parent_canon));
     under(a) || under(b)
 }
+
+#[cfg(test)]
+mod wire_url_tests {
+    use super::{forum_thread_web_url, item_path_for_api_in_room};
+
+    #[test]
+    fn public_room_unchanged() {
+        let u = "https://slug.social/~/a/b";
+        assert_eq!(item_path_for_api_in_room(u, "public"), u);
+    }
+
+    #[test]
+    fn private_room_prefixes_ontology() {
+        assert_eq!(
+            item_path_for_api_in_room("https://slug.social/~/topic/x", "9ab12cd/my-room"),
+            "https://slug.social/r/9ab12cd/my-room/~/topic/x"
+        );
+    }
+
+    #[test]
+    fn private_room_ontology_root() {
+        assert_eq!(
+            item_path_for_api_in_room("https://slug.social/~", "9ab12cd/my-room"),
+            "https://slug.social/r/9ab12cd/my-room/~"
+        );
+        assert_eq!(
+            item_path_for_api_in_room("https://slug.social/~/", "9ab12cd/my-room"),
+            "https://slug.social/r/9ab12cd/my-room/~"
+        );
+    }
+
+    #[test]
+    fn external_url_untouched_in_private_room() {
+        let u = "https://example.com/z";
+        assert_eq!(item_path_for_api_in_room(u, "9ab12cd/my-room"), u);
+    }
+
+    #[test]
+    fn forum_web_public_vs_room() {
+        assert_eq!(
+            forum_thread_web_url("public", "debate"),
+            "https://slug.social/t/debate"
+        );
+        assert_eq!(
+            forum_thread_web_url("9ab12cd/my-room", "#debate"),
+            "https://slug.social/r/9ab12cd/my-room/t/debate"
+        );
+    }
+}
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 31f5fcfb4eaf4df0a9cbac532dd3dedfe3611810..5b91f5836625eedbb1cd9423168046e3fb576c17 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -27,8 +27,9 @@ use crate::{
 
 use super::auth::verify_bearer_principal;
 use super::helpers::{
-    compute_connectivity_stats, is_pair_voted, item_path_for_api, now_ms, paginate_rankings,
-    parse_parent_specs, pick_random_distinct, resolve_item, vote_touches_path,
+    compute_connectivity_stats, forum_thread_web_url, is_pair_voted, item_path_for_api,
+    item_path_for_api_in_room, now_ms, paginate_rankings, parse_parent_specs, pick_random_distinct,
+    resolve_item, vote_touches_path,
 };
 use super::validate::{normalize_room_and_thread, validate_ingest_document};
 
@@ -148,6 +149,7 @@ fn compute_scope_rank_changes(
     parent: &str,
     before: &crate::scope_rank::ChildrenRankings,
     after: &crate::scope_rank::ChildrenRankings,
+    room_wire: &str,
 ) -> Option<ScopeRankChanges> {
     fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap<String, Option<RankPosition>> {
         let mut map = HashMap::new();
@@ -182,7 +184,7 @@ fn compute_scope_rank_changes(
         };
         if changed {
             changes.push(RankChange {
-                item: item_path_for_api(&item),
+                item: item_path_for_api_in_room(&item, room_wire),
                 before: b,
                 after: a,
             });
@@ -204,7 +206,7 @@ fn compute_scope_rank_changes(
         parent: if parent.is_empty() {
             "/".to_string()
         } else {
-            item_path_for_api(parent)
+            item_path_for_api_in_room(parent, room_wire)
         },
         changes,
     })
@@ -256,6 +258,7 @@ fn build_rank_response_for_content(
     offset: usize,
     limit: Option<usize>,
     want_percent: bool,
+    room_wire: &str,
 ) -> Result<RankResponse, RpcErr> {
     let parent_owned = parent.map(|s| s.to_string());
     let specs = parse_parent_specs(parent_owned.as_ref());
@@ -299,7 +302,7 @@ fn build_rank_response_for_content(
                     .ranked
                     .into_iter()
                     .map(|r| RankRow {
-                  

… preview truncated; 42,928 characters omitted

download full diff A

B — c_abd68b5e771a (tommy-mor)

message

[81de487b] Fix zero-ratio guard in reducer to drop before registering items or pair.

Previously the early-return for zero-weight votes happened after
ensure_item and voted_pairs.insert, leaving ghost items in the index
and the pair incorrectly marked as voted. Move the check to before
any side effects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

diff preview

diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 6841d35cfc9de2389f340a22b8a45acb335e36c3..0e36979abe0f051493038ff7e652efc7f7a0ac80 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -112,6 +112,10 @@ impl GroupState {
         if vote.ratio_right < 0 {
             vote.ratio_right = 0;
         }
+        if vote.ratio_left == 0 || vote.ratio_right == 0 {
+            // Zero on either side produces no valid edge; drop before registering items or pair.
+            return;
+        }
 
         let a_idx = self.ensure_item(&vote.a);
         let b_idx = self.ensure_item(&vote.b);
@@ -121,10 +125,6 @@ impl GroupState {
 
         let w_a = vote.ratio_left as f64;
         let w_b = vote.ratio_right as f64;
-        if w_a == 0.0 || w_b == 0.0 {
-            // Zero on either side produces no valid edge; drop the vote.
-            return;
-        }
 
         self.add_edge_weight(b_idx, a_idx, w_a);
         self.add_edge_weight(a_idx, b_idx, w_b);
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index cc8c1a0d139f3722ba6ecd13dd001c65be835b67..08159f4a7f0850fd165817a4a1af4f31ced2ad76 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -546,12 +546,10 @@ fn reducer_negative_ratio_clamped_to_zero() {
         delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
         thread_tag: "t".to_string(),
     });
-    // Items are registered, but the zero-clamped vote produces no edges.
-    assert_eq!(group.idx_to_item.len(), 2);
-    let a_idx = group.item_to_idx[&item_id("https://slug.social/~/t/a")];
-    let b_idx = group.item_to_idx[&item_id("https://slug.social/~/t/b")];
-    assert!(!group.edges.contains_key(&(a_idx, b_idx)));
-    assert!(!group.edges.contains_key(&(b_idx, a_idx)));
+    // Nothing registered: zero-clamped vote is dropped before ensure_item.
+    assert!(group.idx_to_item.is_empty());
+    assert!(group.edges.is_empty());
+    assert!(group.voted_pairs.is_empty());
 }
 
 

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.