constitution · epochs · watch · epoch 3

comparison

c_b8e80699547c (tommy-mor) vs c_cf0a514b261a (tommy-mor)

download prompt · raw event · cmp_672ce58b28b2de

council reasoning

~anthropic/claude-sonnet-latest · winner B · 65:35 · permalink

Side B is a small, precise bugfix that removes dead/redundant logic in a live reducer and updates the corresponding test with correct reasoning, improving code clarity and correctness in production code. Side A is a large, unintegrated new file (reddit.rs) with no evidence of being wired into the rest of the codebase, tests, or build, making its lasting value speculative compared to B's verified, real correctness fix.

~x-ai/grok-latest · winner A · 12:1 · permalink

A adds a full RedditClient module (rate limiting, request helpers, and robust serde types/deserializers for posts/comments), which is substantial lasting infrastructure. B only deletes a redundant zero-ratio early-return already enforced upstream and tweaks the related test, a correct but minimal dead-code cleanup.

openai/gpt-chat-latest · winner A · 9:1 · permalink

Side A adds a substantial new Reddit API client with rate limiting, HTTP request handling, response/error processing, typed serde models, custom deserializers, and helper methods for working with posts and comments, introducing significant reusable functionality. Side B is a targeted cleanup that removes a redundant zero-ratio guard in the reducer and updates the test to reflect existing behavior where zero-weight edges are already skipped, which is a useful but much smaller maintenance improvement.

sides

A — c_b8e80699547c (tommy-mor)

message

[432e1450] nice

diff preview

diff --git a/reddit.rs b/reddit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..da27f6f76482dd27d44cad9d7b844972bf042e64
--- /dev/null
+++ b/reddit.rs
@@ -0,0 +1,420 @@
+use governor::{Quota, RateLimiter, Jitter};
+use nonzero_ext::nonzero;
+use reqwest::{Client, StatusCode};
+use serde::{Deserialize, Serialize};
+use std::sync::Arc;
+use std::time::Duration;
+use anyhow::{Result, Context, anyhow};
+
+/// Reddit API client with built-in rate limiting
+pub struct RedditClient {
+    client: Client,
+    limiter: Arc<governor::DefaultDirectRateLimiter>,
+    user_agent: String,
+}
+
+impl RedditClient {
+    /// Create a new Reddit client with rate limiting
+    /// Reddit API allows 60 requests per minute for OAuth2 authenticated apps
+    /// We'll be conservative and use 50 requests per minute
+    pub fn new() -> Self {
+        // Create rate limiter: 50 requests per minute
+        let quota = Quota::per_minute(nonzero!(50u32));
+        let limiter = Arc::new(RateLimiter::direct(quota));
+        
+        // Create HTTP client with timeout
+        let client = Client::builder()
+            .timeout(Duration::from_secs(30))
+            .build()
+            .expect("Failed to create HTTP client");
+        
+        // Reddit requires a unique user agent
+        let user_agent = format!(
+            "rust:sorter:v{} (by /u/hourLong_arnould)",
+            env!("CARGO_PKG_VERSION")
+        );
+        
+        Self {
+            client,
+            limiter,
+            user_agent,
+        }
+    }
+    
+    /// Fetch hot posts from a subreddit
+    pub async fn get_subreddit_hot(&self, subreddit: &str, limit: usize) -> Result<RedditListingResponse> {
+        // Wait for rate limiter
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!("https://www.reddit.com/r/{}/hot.json?limit={}", subreddit, limit);
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Fetch top posts from a subreddit
+    pub async fn get_subreddit_top(&self, subreddit: &str, limit: usize, time_period: &str) -> Result<RedditListingResponse> {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!(
+            "https://www.reddit.com/r/{}/top.json?limit={}&t={}",
+            subreddit, limit, time_period
+        );
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Fetch new posts from a subreddit
+    pub async fn get_subreddit_new(&self, subreddit: &str, limit: usize) -> Result<RedditListingResponse> {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!("https://www.reddit.com/r/{}/new.json?limit={}", subreddit, limit);
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Fetch a specific post and its comments
+    pub async fn get_post(&self, subreddit: &str, post_id: &str) -> Result<Vec<RedditListingResponse>> {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!(
+            "https://www.reddit.com/r/{}/comments/{}.json",
+            subreddit, post_id
+        );
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        match response.status() {
+            StatusCode::OK => {
+                let listings: Vec<RedditListingResponse> = response
+                    .json()
+                    .await
+                    .context("Failed to parse Reddit response")?;
+                Ok(listings)
+            }
+            StatusCode::TOO_MANY_REQUESTS => {
+                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+            }
+            StatusCode::NOT_FOUND => {
+                Err(anyhow!("Post not found: r/{}/comments/{}", subreddit, post_id))
+            }
+            status => {
+                Err(anyhow!("Reddit API error: {}", status))
+            }
+        }
+    }
+    
+    /// Fetch user profile (posts and comments)
+    pub async fn get_user_profile(&self, username: &str, content_type: &str, limit: usize) -> Result<RedditListingResponse> {
+        self.limiter.until_ready_with_jitter(Jitter::up_to(Duration::from_millis(100))).await;
+        
+        let url = format!(
+            "https://www.reddit.com/user/{}/{}.json?limit={}",
+            username, content_type, limit
+        );
+        
+        let response = self.client
+            .get(&url)
+            .header("User-Agent", &self.user_agent)
+            .send()
+            .await
+            .context("Failed to send request to Reddit")?;
+        
+        self.handle_response(response).await
+    }
+    
+    /// Handle Reddit API response with proper error checking
+    async fn handle_response(&self, response: reqwest::Response) -> Result<RedditListingResponse> {
+        match response.status() {
+            StatusCode::OK => {
+                let listing: RedditListingResponse = response
+                    .json()
+                    .await
+                    .context("Failed to parse Reddit response")?;
+                Ok(listing)
+            }
+            StatusCode::TOO_MANY_REQUESTS => {
+                Err(anyhow!("Reddit rate limit exceeded. Please try again later."))
+            }
+            StatusCode::NOT_FOUND => {
+                Err(anyhow!("Reddit resource not found"))
+            }
+            StatusCode::FORBIDDEN => {
+                Err(anyhow!("Access forbidden. The subreddit may be private."))
+            }
+            status => {
+                Err(anyhow!("Reddit API error: {}", status))
+            }
+        }
+    }
+}
+
+// Reddit API response types
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingResponse {
+    pub kind: String,
+    pub data: RedditListingData,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditListingData {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub after: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub before: Option<String>,
+    pub children: Vec<RedditThing>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub modhash: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditThing {
+    pub kind: String,
+    pub data: RedditThingData,
+}
+
+/// Reddit's "Thing" data can be either a post, comment, or other types
+/// We use untagged enum to handle different data structures
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditThingData {
+    Post(RedditPost),
+    Comment(RedditComment),
+    // For things we don't care about yet (like "more" comments)
+    Other(serde_json::Value),
+}
+
+/// Reddit post data with serde handling all the parsing
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditPost {
+    pub id: String,
+    pub name: String, // Full name (t3_xxx)
+    #[serde(default = "default_untitled")]
+    pub title: String,
+    #[serde(default = "default_deleted_user")]
+    pub author: String,
+    pub subreddit: String,
+    #[serde(default)]
+    pub url: String,
+    #[serde(default)]
+    pub permalink: String,
+    #[serde(default, deserialize_with = "deserialize_selftext")]
+    pub selftext: Option<String>,
+    #[serde(default)]
+    pub score: i64,
+    #[serde(default)]
+    pub num_comments: i64,
+    #[serde(default)]
+    pub created_utc: f64,
+    #[serde(default, deserialize_with = "deserialize_thumbnail")]
+    pub thumbnail: Option<String>,
+    #[serde(default)]
+    pub is_video: bool,
+    #[serde(default)]
+    pub is_self: bool,
+    // Additional useful fields
+    #[serde(default)]
+    pub ups: i64,
+    #[serde(default)]
+    pub downs: i64,
+    #[serde(default)]
+    pub upvote_ratio: f64,
+    #[serde(default)]
+    pub over_18: bool,
+    #[serde(default)]
+    pub spoiler: bool,
+    #[serde(default)]
+    pub stickied: bool,
+    #[serde(default)]
+    pub locked: bool,
+    #[serde(default)]
+    pub distinguished: Option<String>,
+}
+
+/// Reddit comment data
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RedditComment {
+    pub id: String,
+    pub name: String, // Full name (t1_xxx)
+    #[serde(default = "default_deleted_user")]
+    pub author: String,
+    #[serde(default)]
+    pub body: String,
+    #[serde(default)]
+    pub score: i64,
+    #[serde(default)]
+    pub created_utc: f64,
+    #[serde(default)]
+    pub parent_id: String,
+    #[serde(default)]
+    pub permalink: String,
+    #[serde(default)]
+    pub depth: i32,
+    // Additional useful fields
+    #[serde(default)]
+    pub ups: i64,
+    #[serde(default)]
+    pub downs: i64,
+    #[serde(default)]
+    pub edited: RedditEditStatus,
+    #[serde(default)]
+    pub stickied: bool,
+    #[serde(default)]
+    pub distinguished: Option<String>,
+    #[serde(default)]
+    pub is_submitter: bool,
+    #[serde(default)]
+    pub collapsed: bool,
+    #[serde(default)]
+    pub controversiality: i32,
+}
+
+/// Reddit's edit status - can be false or a timestamp
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum RedditEditStatus {
+    NotEdited(bool),
+    EditedAt(f64),
+}
+
+impl Default for RedditEditStatus {
+    fn default() -> Self {
+        RedditEditStatus::NotEdited(false)
+    }
+}
+
+// Helper functions for serde defaults
+fn default_untitled() -> String {
+    "Untitled".to_string()
+}
+
+fn default_deleted_user() -> String {
+    "[deleted]".to_string()
+}
+
+// Custom deserializer for selftext (empty strings should be None)
+fn deserialize_selftext<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let s: Option<String> = Option::deserialize(deserializer)?;
+    Ok(s.filter(|text| !text.is_empty()))
+}
+
+// Custom deserializer for thumbnail (filter out "self", "default", empty)
+fn deserialize_thumbnail<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
+where
+    D: serde::Deserializer<'de>,
+{
+    let s: Option<String> = Option::deserialize(deserializer)?;
+    Ok(s.filter(|thumb| {
+        !thumb.is_empty() && thumb != "self" && thumb != "default" && thumb != "nsfw" && thumb != "spoiler"
+    }))
+}
+
+// Helper methods for extracting typed data from Things
+impl RedditThing {
+    /// Try to get this Thing as a Post
+    pub fn as_post(&self) -> Option<&RedditPost> {
+        if self.kind != "t3" {
+            return None;
+        }
+        match &self.data {
+            RedditThingData::Post(post) => Some(post),
+            _ => None,
+        }
+    }
+    
+    /// Try to get this Thing as a Comment
+    pub fn as_comment(&self) -> Option<&RedditComment> {
+        if self.kind != "t1" {
+            return None;
+        }
+        match &self.data {
+            RedditThingData::Comment(comment) => Some(comment),
+            _ => None,
+        }
+    }
+    
+    /// Check if th

… preview truncated; 1,666 characters omitted

download full diff A

B — c_cf0a514b261a (tommy-mor)

message

[047b82bd] Remove redundant zero-ratio guard from reducer.

Zeros are already rejected at the DSL parser and browser handler;
the guard in apply_vote was dead code. The negative clamping stays
since add_edge_weight already skips weight-0 edges correctly.

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 0e36979abe0f051493038ff7e652efc7f7a0ac80..efbf7f67ff24c7a2989102fe62879b2794a27c5f 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -112,11 +112,6 @@ 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);
 
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index 08159f4a7f0850fd165817a4a1af4f31ced2ad76..1748769ccf7196b2d81cf556d5368c7e723f6a4e 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -533,7 +533,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {
 #[test]
 fn reducer_negative_ratio_clamped_to_zero() {
     let _state = ReducerState::default();
-    // GroupState::apply_vote clamps negatives to 0; when either side is 0 the vote is dropped.
+    // apply_vote clamps negatives to 0; add_edge_weight skips zero-weight edges.
     let mut group = GroupState::new();
     group.apply_vote(slugsocial_server::reducer::VoteData {
         ts: 1,
@@ -546,10 +546,9 @@ fn reducer_negative_ratio_clamped_to_zero() {
         delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
         thread_tag: "t".to_string(),
     });
-    // Nothing registered: zero-clamped vote is dropped before ensure_item.
-    assert!(group.idx_to_item.is_empty());
+    // Items and pair are registered; edges are absent because weight 0 is skipped.
+    assert_eq!(group.idx_to_item.len(), 2);
     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.