constitution · epochs · watch · epoch 3

comparison

c_b8e80699547c (tommy-mor) vs c_16438843de8f (tommy-mor)

download prompt · raw event · cmp_b075b7da27d03f

council reasoning

~anthropic/claude-sonnet-latest · winner B · 6:4 · permalink

Side B adds several genuinely integrated, distinct project pieces (deployment config via Dockerfile/fly.toml, a working async EventLog with corruption handling, a persistent ViewStore with debounced disk flushing, and a Clojure deps.edn), each solving a real infrastructure/persistence need. Side A is a single self-contained Reddit API client that is reasonably well structured but is a single-purpose module whose value is more speculative/isolated, with less evidence of integration into the rest of the system.

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

Side A delivers a complete, well-engineered Reddit API client (rate limiting via governor, full serde types for posts/comments, custom deserializers, and extraction helpers) that is directly usable and lasting application logic. Side B adds necessary but thinner infrastructure (Dockerfile/fly.toml boilerplate plus simpler EventLog and ViewStore modules), which supports running the service yet contributes less distinctive design substance.

openai/gpt-chat-latest · winner A · 3:2 · permalink

Side A introduces a substantial Reddit API client with built-in rate limiting, HTTP request handling, typed serde models for posts/comments, custom deserializers, and reusable helper methods, forming a coherent new subsystem. Side B adds useful infrastructure such as Docker/Fly deployment files plus persistent event logging and view counting, but much of the patch is operational configuration, and the new code is narrower in scope than the complete API client added in Side A.

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_16438843de8f (tommy-mor)

message

[4cd0d15d] more seed

diff preview

diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..9cb07c60cb0da063f747cfbf1b3b876ecb8ba03e
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# time 0.3.47+ requires Rust 1.88 (edition 2024)
+FROM rust:1.88-slim as builder
+
+WORKDIR /build
+
+RUN apt-get update && \
+    apt-get install -y pkg-config libssl-dev && \
+    rm -rf /var/lib/apt/lists/*
+
+# Copy source and build. (Keep it simple to avoid remote build cache oddities.)
+COPY . .
+RUN cargo build --release --package slugsocial-server
+
+FROM debian:bookworm-slim
+
+RUN apt-get update && \
+    apt-get install -y ca-certificates && \
+    rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY --from=builder /build/target/release/slugsocial-server /app/slugsocial-server
+
+# Create data directory for persistent volume
+RUN mkdir -p /data
+
+ENV SLUG_DATA_DIR=/data
+ENV SLUG_EVENT_LOG=/data/events.jsonl
+ENV PORT=8080
+
+EXPOSE 8080
+
+CMD ["/app/slugsocial-server"]
+
diff --git a/deps.edn b/deps.edn
new file mode 100644
index 0000000000000000000000000000000000000000..0bf892d44f491cb2313e01ae8a942c3097c52948
--- /dev/null
+++ b/deps.edn
@@ -0,0 +1,10 @@
+{:paths ["." "test"]
+ :deps {cheshire/cheshire {:mvn/version "5.13.0"}
+        http-kit/http-kit {:mvn/version "2.8.0"}
+        babashka/fs {:mvn/version "0.5.32"}
+        babashka/process {:mvn/version "0.6.25"}
+        com.blockether/spel {:mvn/version "0.7.11"}}
+ :aliases
+ {:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
+                        lambdaisland/kaocha-junit-xml {:mvn/version "1.17.101"}}
+          :main-opts ["-m" "kaocha.runner"]}}}
diff --git a/event_log.rs b/event_log.rs
new file mode 100644
index 0000000000000000000000000000000000000000..eaae0d495e43a45d6590603892265a62cc92906e
--- /dev/null
+++ b/event_log.rs
@@ -0,0 +1,83 @@
+use std::path::{Path, PathBuf};
+
+use tokio::{
+    fs::{self, OpenOptions},
+    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
+};
+
+use crate::events::Event;
+
+#[derive(Debug, thiserror::Error)]
+pub enum EventLogError {
+    #[error("io error: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("json error: {0}")]
+    Json(#[from] serde_json::Error),
+}
+
+#[derive(Debug, Clone)]
+pub struct EventLog {
+    path: PathBuf,
+}
+
+impl EventLog {
+    pub fn new(path: impl Into<PathBuf>) -> Self {
+        Self { path: path.into() }
+    }
+
+    pub fn path(&self) -> &Path {
+        &self.path
+    }
+
+    pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {
+        if let Some(parent) = self.path.parent() {
+            fs::create_dir_all(parent).await?;
+        }
+        Ok(())
+    }
+
+    pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {
+        self.ensure_parent_dir().await?;
+        let mut f: tokio::fs::File = OpenOptions::new()
+            .create(true)
+            .append(true)
+            .open(&self.path)
+            .await?;
+
+        let mut line = serde_json::to_string(event)?;
+        line.push('\n');
+        f.write_all(line.as_bytes()).await?;
+        f.flush().await?;
+        Ok(())
+    }
+
+    /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`.
+    pub async fn load_all(&self) -> Result<(Vec<Event>, Vec<(usize, String)>), EventLogError> {
+        if !fs::try_exists(&self.path).await? {
+            return Ok((vec![], vec![]));
+        }
+
+        let f = fs::File::open(&self.path).await?;
+        let mut reader = BufReader::new(f).lines();
+
+        let mut events = Vec::new();
+        let mut bad_lines = Vec::new();
+
+        let mut line_no: usize = 0;
+        while let Some(line) = reader.next_line().await? {
+            line_no += 1;
+            let trimmed = line.trim();
+            if trimmed.is_empty() {
+                continue;
+            }
+            match serde_json::from_str::<Event>(trimmed) {
+                Ok(ev) => events.push(ev),
+                Err(_) => bad_lines.push((line_no, line)),
+            }
+        }
+
+        Ok((events, bad_lines))
+    }
+}
+
+
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000000000000000000000000000000000000..bbb9345e527452db1d87a549213645c195eae5fc
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,42 @@
+app = "slugsocial"
+primary_region = "iad"
+
+[build]
+  dockerfile = "Dockerfile"
+
+[env]
+  SLUG_DATA_DIR = "/data"
+  SLUG_EVENT_LOG = "/data/events.jsonl"
+  PORT = "8080"
+
+[[services]]
+  internal_port = 8080
+  protocol = "tcp"
+
+  [[services.ports]]
+    port = 80
+    handlers = ["http"]
+    force_https = true
+
+  [[services.ports]]
+    port = 443
+    handlers = ["tls", "http"]
+
+  [services.concurrency]
+    type = "connections"
+    hard_limit = 1000
+    soft_limit = 500
+
+  [[services.http_checks]]
+    interval = "10s"
+    timeout = "2s"
+    grace_period = "5s"
+    method = "GET"
+    path = "/healthz"
+    protocol = "http"
+    tls_skip_verify = false
+
+[[mounts]]
+  source = "slugsocial_data"
+  destination = "/data"
+
diff --git a/views.rs b/views.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d4f0ffc49475f014698b4da0de6f476884430813
--- /dev/null
+++ b/views.rs
@@ -0,0 +1,63 @@
+use std::{
+    collections::HashMap,
+    sync::{Arc, Mutex},
+};
+use tokio::sync::mpsc;
+
+type CountMap = Arc<Mutex<HashMap<String, u64>>>;
+
+#[derive(Clone)]
+pub struct ViewStore {
+    counts: CountMap,
+    flush_tx: mpsc::Sender<()>,
+}
+
+impl ViewStore {
+    pub fn new(json_path: &str) -> Self {
+        // Load existing counts from disk on startup (best-effort)
+        let initial: HashMap<String, u64> = std::fs::read_to_string(json_path)
+            .ok()
+            .and_then(|s| serde_json::from_str(&s).ok())
+            .unwrap_or_default();
+
+        let counts: CountMap = Arc::new(Mutex::new(initial));
+        let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);
+        let path = json_path.to_string();
+
+        let counts_for_writer = counts.clone();
+        tokio::spawn(async move {
+            while flush_rx.recv().await.is_some() {
+                while flush_rx.try_recv().is_ok() {}
+
+                let snapshot: HashMap<String, u64> = {
+                    counts_for_writer.lock().unwrap().clone()
+                };
+
+                let path = path.clone();
+                let _ = tokio::task::spawn_blocking(move || {
+                    if let Ok(json) = serde_json::to_string(&snapshot) {
+                        let tmp = format!("{path}.tmp");
+                        if std::fs::write(&tmp, &json).is_ok() {
+                            let _ = std::fs::rename(&tmp, &path);
+                        }
+                    }
+                })
+                .await;
+            }
+        });
+
+        Self { counts, flush_tx }
+    }
+
+    pub fn increment(&self, path: String) {
+        {
+            let mut map = self.counts.lock().unwrap();
+            *map.entry(path).or_insert(0) += 1;
+        }
+        let _ = self.flush_tx.try_send(());
+    }
+
+    pub fn get_views(&self, path: &str) -> u64 {
+        self.counts.lock().unwrap().get(path).copied().unwrap_or(0)
+    }
+}

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.