constitution · epochs · watch · epoch 3

comparison

c_06fce70179bc (tommy-mor) vs c_6209cd238b3f (tommy-mor)

download prompt · raw event · cmp_864a3117552352

council reasoning

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

B fixes a concrete performance bug (scan was doing a full reducer replay including rank centrality, which is slow) with a single-pass parse check, adds a genuinely useful debugging tool (compile --ingest) with tests, and improves error surfacing without removing existing coverage. A delivers a real SSE-streaming improvement for fetch UX, but it also strips out three existing unit tests without replacement and pads the reddit worker with a large volume of tracing::debug! calls that add noise more than lasting design value.

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

A lands a lasting product design change: fetch becomes a oneshot-backed SSE stream with FetchJobResult, a dedicated fetch module, and real client progressive morph/error handling instead of fire-and-forget queueing. B is strong, focused tooling (fast parse-only scan, full parse_error surfaces, compile --ingest), but it improves offline DX rather than core runtime behavior and is narrower in impact.

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

Side B adds substantial offline tooling improvements: it introduces `compile --ingest` to replay prior events and compile a single ingest from a log, makes `scan` a fast parse-only pass instead of replaying the full reducer, and surfaces detailed `parse_error` information in both data structures and CLI output. Side A implements an SSE-based fetch flow with UI refactoring and async coordination, but much of the patch is infrastructure and code movement around an existing fetch feature, whereas Side B delivers durable developer-facing functionality and performance improvements.

sides

A — c_06fce70179bc (tommy-mor)

message

[6d04afc2] refactor

diff preview

diff --git a/Cargo.lock b/Cargo.lock
index 2cea973082716e761ef6f5dd5886acc08ff9aac0..8c43fb75c472b102e6e1d3b837dce3355be898f2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -17,6 +17,28 @@ version = "1.0.102"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
 
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "async-trait"
 version = "0.1.89"
@@ -1242,9 +1264,11 @@ dependencies = [
 name = "sorter2-server"
 version = "0.0.1"
 dependencies = [
+ "async-stream",
  "axum",
  "axum-extra",
  "dotenvy",
+ "futures-util",
  "maud",
  "reqwest",
  "serde",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index bd600138b613bd0f546bdec217a5334cdcb20aa5..c940acb687fb141d21760a3d6656172013cf6f41 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -18,6 +18,8 @@ tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 reqwest = { version = "0.12", features = ["json"] }
 dotenvy = "0.15"
+async-stream = "0.3"
+futures-util = { version = "0.3", default-features = false, features = ["std"] }
 
 [dev-dependencies]
 reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index b33a84e8bb5e817b26592868d88090e6d664d950..7af6527d03c483f33f3469ce6766c01a554c5fe3 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,8 @@ use axum::{
 use std::collections::HashMap;
 
 use crate::{
-    html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},
+    fetch,
+    html::{input_panel, js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
     path_types::ItemId,
     reddit::ensure_partial_tree,
@@ -89,18 +90,8 @@ pub async fn post_ui_html(
         },
         HtmlUiAction::FetchEntity { item } => {
             let id = parse_item_param(&item);
-            if id.is_root() {
-                return ui_js_warn("nothing to fetch for the root").into_response();
-            }
-            state.queue_entity_fetch(id.clone());
-            let tree = state.tree.read().await;
-            let empty = crate::reducer::NodeState::default();
-            let node = tree.get(&id).unwrap_or(&empty);
-            let panel = entity_section(&id, node, true);
-            JsBuilder::new()
-                .morph_selector("#entity-section", panel)
-                .into_response()
-        },
+            fetch::fetch_entity_stream(state, id).into_response()
+        }
     }
 }
 
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
new file mode 100644
index 0000000000000000000000000000000000000000..63634508496e224c38b9ec0308b7a6086462f925
--- /dev/null
+++ b/server/src/fetch/html.rs
@@ -0,0 +1,67 @@
+//! Markup for entity import / “Fetch from Reddit” (`POST /ui`, SSE response).
+
+use maud::{html, Markup};
+
+use crate::{
+    form_template::template_json_compact,
+    path_types::ItemId,
+    reddit::is_fetchable,
+    reducer::NodeState,
+    ui_action::UI_RPC_FIELD,
+};
+
+fn entity_panel(node: &NodeState) -> Markup {
+    html! {
+        @if let Some(data) = &node.data {
+            div id="entity-panel" class="entity-card" {
+                h2 { (data.title) }
+                @if let Some(author) = &data.author {
+                    p class="muted small" { "by " (author) }
+                }
+                @if let Some(body) = &data.body_html {
+                    div class="entity-body" { (maud::PreEscaped(body)) }
+                }
+            }
+        }
+    }
+}
+
+/// Reddit/API import — `POST /ui` with `fetch_entity` returns an SSE stream.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+    if !is_fetchable(item) {
+        return html! {};
+    }
+    let label = if fetching {
+        "Fetching…"
+    } else if has_data {
+        "Fetch more"
+    } else {
+        "Fetch from Reddit"
+    };
+    let rpc = template_json_compact(&serde_json::json!({
+        "action": "fetch_entity",
+        "item": item.as_str(),
+    }))
+    .expect("fetch_entity rpc template");
+    html! {
+        form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+            input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
+            @if fetching {
+                button type="submit" class="btn-secondary" disabled { (label) }
+            } @else {
+                button type="submit" class="btn-secondary" { (label) }
+            }
+        }
+    }
+}
+
+/// Entity card + fetch control (target `#entity-section` for Idiomorph / SSE).
+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
+    let has_data = node.data.is_some();
+    html! {
+        section id="entity-section" class="demo-panel" {
+            (entity_panel(node))
+            (fetch_entity_panel(item, has_data, fetching))
+        }
+    }
+}
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2290f9d3a0f1cbf1806c6339f82a4515c11cc3d3
--- /dev/null
+++ b/server/src/fetch/mod.rs
@@ -0,0 +1,115 @@
+//! Entity import over `POST /ui` as SSE (Reddit worker in [`crate::reddit`]).
+
+pub mod html;
+
+use std::convert::Infallible;
+use std::time::Duration;
+
+use async_stream::stream;
+use axum::response::sse::{Event, KeepAlive, Sse};
+use futures_util::Stream;
+use serde::Serialize;
+use tokio::sync::oneshot;
+
+use crate::{
+    path_types::ItemId,
+    reddit::FetchJobResult,
+    reducer::NodeState,
+    state::AppState,
+};
+
+pub fn now_ms() -> i64 {
+    let t = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap_or_default();
+    t.as_millis() as i64
+}
+
+#[derive(Serialize)]
+struct SseMorphPayload {
+    selector: &'static str,
+    html: String,
+}
+
+fn morph_complete_event(html: maud::Markup) -> Event {
+    let payload = SseMorphPayload {
+        selector: "#entity-section",
+        html: html.into_string(),
+    };
+    let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
+    Event::default().event("complete").data(data)
+}
+
+/// Stream `fetching` → `complete` / `error` for [`crate::ui_action::HtmlUiAction::FetchEntity`].
+pub fn fetch_entity_stream(
+    state: AppState,
+    id: ItemId,
+) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
+    tracing::debug!(item = %id, "fetch entity stream opened");
+
+    let stream = stream! {
+        if id.is_root() {
+            yield Ok(Event::default().event("error").data("{\"message\":\"nothing to fetch for the root\"}"));
+            return;
+        }
+
+        if !crate::reddit::is_fetchable(&id) {
+            tracing::debug!(item = %id, "fetch stream: not fetchable");
+            yield Ok(Event::default().event("error").data("{\"message\":\"this page cannot be fetched from Reddit\"}"));
+            return;
+        }
+
+        let fetching_html = {
+            let tree = state.tree.read().await;
+            let empty = NodeState::default();
+            let node = tree.get(&id).unwrap_or(&empty);
+            html::entity_section(&id, node, true).into_string()
+        };
+        let fetching_payload = serde_json::json!({
+            "selector": "#entity-section",
+            "html": fetching_html,
+        });
+        yield Ok(Event::default().event("fetching").data(fetching_payload.to_string()));
+
+        let (tx, rx) = oneshot::channel();
+        state.reddit.request_fetch(id.clone(), true, Some(tx));
+        tracing::debug!(item = %id, "fetch stream: queued reddit job");
+
+        let result = match rx.await {
+            Ok(r) => r,
+            Err(_) => {
+                tracing::warn!(item = %id, "fetch stream: worker dropped oneshot");
+                FetchJobResult::Failed("reddit worker stopped".into())
+            }
+        };
+
+        tracing::debug!(item = %id, ?result, "fetch stream: job finished");
+
+        match result {
+            FetchJobResult::Imported | FetchJobResult::NotFound => {
+                let tree = state.tree.read().await;
+                let empty = NodeState::default();
+                let node = tree.get(&id).unwrap_or(&empty);
+                yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+            }
+            FetchJobResult::SkippedCached | FetchJobResult::SkippedDuplicate => {
+                let tree = state.tree.read().await;
+                let empty = NodeState::default();
+                let node = tree.get(&id).unwrap_or(&empty);
+                yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+            }
+            FetchJobResult::RateLimited { reset_secs } => {
+                yield Ok(Event::default().event("error").data(
+                    serde_json::json!({"message": format!("Reddit rate limit — retry in {reset_secs}s")}).to_string(),
+                ));
+            }
+            FetchJobResult::Failed(msg) => {
+                yield Ok(Event::default().event("error").data(
+                    serde_json::json!({"message": msg}).to_string(),
+                ));
+            }
+        }
+    };
+
+    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
+}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index db5b4c7f06b0be64603981166835cde268234f67..9314a7556306ddab969b896dbf4126b542a46722 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -7,10 +7,10 @@ use axum::{
 use maud::{html, Markup, DOCTYPE};
 
 use crate::{
+    fetch::html::entity_section,
     form_template::template_json_compact,
     path_types::ItemId,
     ranking::{top_bottom, RankedItem},
-    reddit::is_fetchable,
     reducer::{GroupState, NodeState},
     state::AppState,
     ui_action::UI_RPC_FIELD,
@@ -149,62 +149,6 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {
     }
 }
 
-fn entity_panel(node: &NodeState) -> Markup {
-    html! {
-        @if let Some(data) = &node.data {
-            div id="entity-panel" class="entity-card" {
-                h2 { (data.title) }
-                @if let Some(author) = &data.author {
-                    p class="muted small" { "by " (author) }
-                }
-                @if let Some(body) = &data.body_html {
-                    div class="entity-body" { (maud::PreEscaped(body)) }
-                }
-            }
-        }
-    }
-}
-
-/// Reddit/API import control — only shown on fetchable pages; never auto-fires.
-pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
-    if !is_fetchable(item) {
-        return html! {};
-    }
-    let label = if fetching {
-        "Fetching…"
-    } else if has_data {
-        "Fetch more"
-    } else {
-        "Fetch from Reddit"
-    };
-    let rpc = template_json_compact(&serde_json::json!({
-        "action": "fetch_entity",
-        "item": item.as_str(),
-    }))
-    .expect("fetch_entity rpc template");
-    html! {
-        form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
-            input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
-            @if fetching {
-                button type="submit" class="btn-secondary" disabled { (label) }
-            } @else {
-                button type="submit" class="btn-secondary" { (label) }
-            }
-        }
-    }
-}
-
-/// Entity 

… preview truncated; 22,618 characters omitted

download full diff A

B — c_6209cd238b3f (tommy-mor)

message

[6e344666] Improve sorterc scan speed, errors, and ingest compile.

Make scan a fast DSL parse pass with human-readable output, surface full parse_error details, and add compile --ingest for single-event replay from a log.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/server/src/offline.rs b/server/src/offline.rs
index 54ad0ded096a305ef8454ab2cdd1c3af71b14f5d..2db6f67e22e00a24ce673d13095644dd9fa9342d 100644
--- a/server/src/offline.rs
+++ b/server/src/offline.rs
@@ -28,6 +28,10 @@ pub struct CompileResult {
     pub threads: Vec<String>,
     pub rankings: Vec<CheckScopeRanking>,
     pub stats: CompileStats,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub ingest_id: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub ingest_line: Option<usize>,
 }
 
 #[derive(Debug, Clone, Serialize)]
@@ -36,6 +40,8 @@ pub struct CompileError {
     pub error: String,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub hint: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub parse_error: Option<String>,
 }
 
 #[derive(Debug, Clone, Serialize)]
@@ -50,7 +56,7 @@ pub struct MalformedIngest {
     pub id: String,
     pub room_id: String,
     pub thread_tag: String,
-    pub reason: String,
+    pub parse_error: String,
 }
 
 #[derive(Debug, Clone, Serialize)]
@@ -59,9 +65,36 @@ pub struct ScanResult {
     pub path: String,
     pub total_lines: usize,
     pub parsed_events: usize,
+    pub ingest_events: usize,
     pub bad_json_lines: Vec<BadJsonLine>,
     pub malformed_ingests: Vec<MalformedIngest>,
-    pub skipped_ingests: usize,
+}
+
+#[derive(Debug)]
+pub enum CompileIngestError {
+    NotFound(String),
+    Io(std::io::Error),
+    Compile(CompileError),
+}
+
+impl CompileIngestError {
+    pub fn into_compile_error(self) -> CompileError {
+        match self {
+            Self::NotFound(id) => CompileError {
+                ok: false,
+                error: format!("ingest not found: {id}"),
+                hint: Some("pass the ingest event id from events.jsonl".into()),
+                parse_error: None,
+            },
+            Self::Io(e) => CompileError {
+                ok: false,
+                error: format!("io error: {e}"),
+                hint: None,
+                parse_error: None,
+            },
+            Self::Compile(e) => e,
+        }
+    }
 }
 
 fn document_stats(doc: &dsl::Document) -> CompileStats {
@@ -166,19 +199,22 @@ fn rankings_for_simulated(
         .collect()
 }
 
-/// Validate and simulate one `.sorter` document against optional base reducer state.
-pub fn compile_document(
+fn compile_document_inner(
     base: &ReducerState,
     room: &str,
     text: &str,
+    ingest_id: Option<String>,
+    ingest_line: Option<usize>,
 ) -> Result<CompileResult, CompileError> {
     let room_key = room.trim();
     let scope = scope_from_room_wire(room_key);
     let validated = validate_ingest_document(base, text, &scope).map_err(|(_, message, hint)| {
+        let parse_error = dsl::parse_full(text).err().map(|e| e.to_string());
         CompileError {
             ok: false,
             error: message,
             hint,
+            parse_error,
         }
     })?;
 
@@ -200,15 +236,27 @@ pub fn compile_document(
         threads: threads_in_document(text),
         rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc),
         stats: document_stats(&validated.doc),
+        ingest_id,
+        ingest_line,
     })
 }
 
+/// Validate and simulate one `.sorter` document against optional base reducer state.
+pub fn compile_document(
+    base: &ReducerState,
+    room: &str,
+    text: &str,
+) -> Result<CompileResult, CompileError> {
+    compile_document_inner(base, room, text, None, None)
+}
+
 fn ingest_parse_error(raw: &str) -> Option<String> {
     dsl::parse_full(raw).err().map(|e| e.to_string())
 }
 
-fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error> {
+fn load_events_from_jsonl(path: &Path) -> Result<(usize, Vec<(usize, Event)>, Vec<BadJsonLine>), std::io::Error> {
     let text = std::fs::read_to_string(path)?;
+    let total_lines = text.lines().count();
     let mut events = Vec::new();
     let mut bad_json_lines = Vec::new();
     for (idx, line) in text.lines().enumerate() {
@@ -225,61 +273,90 @@ fn load_events_from_jsonl(path: &Path) -> Result<(Vec<(usize, Event)>, Vec<BadJs
             }),
         }
     }
-    Ok((events, bad_json_lines))
+    Ok((total_lines, events, bad_json_lines))
 }
 
-/// Replay a JSONL event log into reducer state (same rules as server boot).
-pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec<BadJsonLine>), std::io::Error> {
-    let (events, bad_json_lines) = load_events_from_jsonl(path)?;
+fn replay_events(events: &[(usize, Event)]) -> ReducerState {
     let mut state = ReducerState::default();
     for (_line_no, ev) in events {
-        state.apply_event(ev);
+        state.apply_event(ev.clone());
     }
-    Ok((state, bad_json_lines))
+    state
 }
 
-/// Scan an events.jsonl for corrupt JSON lines and ingests that fail DSL replay.
-pub fn scan_jsonl(path: &Path) -> Result<ScanResult, std::io::Error> {
-    let text = std::fs::read_to_string(path)?;
-    let total_lines = text.lines().count();
-    let (events, bad_json_lines) = load_events_from_jsonl(path)?;
+/// Replay a JSONL event log into reducer state (same rules as server boot).
+pub fn load_reducer_from_jsonl(path: &Path) -> Result<(ReducerState, Vec<BadJsonLine>), std::io::Error> {
+    let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;
+    Ok((replay_events(&events), bad_json_lines))
+}
 
-    let mut malformed_ingests = Vec::new();
-    let mut skipped_ingests = 0usize;
-    let mut state = ReducerState::default();
-    let parsed_events = events.len();
+/// Find one ingest in a log and compile it against all prior events as base state.
+pub fn compile_ingest_from_log(path: &Path, ingest_id: &str) -> Result<CompileResult, CompileIngestError> {
+    let (_total_lines, events, bad_json_lines) = load_events_from_jsonl(path).map_err(CompileIngestError::Io)?;
+    if !bad_json_lines.is_empty() {
+        return Err(CompileIngestError::Compile(CompileError {
+            ok: false,
+            error: format!("jsonl has {} corrupt line(s)", bad_json_lines.len()),
+            hint: Some("fix the log or use `sorterc scan`".into()),
+            parse_error: None,
+        }));
+    }
+
+    let needle = ingest_id.trim();
+    let mut found: Option<(usize, Ingest)> = None;
+    let mut prior: Vec<(usize, Event)> = Vec::new();
 
     for (line_no, ev) in events {
         if let Event::Ingest(ref ing) = ev {
-            if let Some(reason) = ingest_parse_error(&ing.raw) {
+            if ing.id == needle {
+                found = Some((line_no, ing.clone()));
+                break;
+            }
+        }
+        prior.push((line_no, ev));
+    }
+
+    let (line_no, ing) = found.ok_or_else(|| CompileIngestError::NotFound(needle.to_string()))?;
+    let base = replay_events(&prior);
+    compile_document_inner(&base, &ing.room_id, &ing.raw, Some(ing.id.clone()), Some(line_no))
+        .map_err(CompileIngestError::Compile)
+}
+
+/// Scan an events.jsonl for corrupt JSON lines and ingests whose DSL fails to parse.
+///
+/// This does not replay the log (which would run rank centrality on every ingest and
+/// can take minutes on real logs). It matches what the server skips on boot: parse failure.
+pub fn scan_jsonl(path: &Path) -> Result<ScanResult, std::io::Error> {
+    let (total_lines, events, bad_json_lines) = load_events_from_jsonl(path)?;
+
+    let mut malformed_ingests = Vec::new();
+    let mut ingest_events = 0usize;
+
+    for (line_no, ev) in &events {
+        if let Event::Ingest(ing) = ev {
+            ingest_events += 1;
+            if let Some(parse_error) = ingest_parse_error(&ing.raw) {
                 malformed_ingests.push(MalformedIngest {
-                    line: line_no,
+                    line: *line_no,
                     id: ing.id.clone(),
                     room_id: ing.room_id.clone(),
                     thread_tag: ing.thread_tag.clone(),
-                    reason,
+                    parse_error,
                 });
             }
-            let before = state.ingests_by_id.len();
-            state.apply_event(ev);
-            if state.ingests_by_id.len() == before {
-                skipped_ingests += 1;
-            }
-        } else {
-            state.apply_event(ev);
         }
     }
 
-    let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty() && skipped_ingests == 0;
+    let ok = bad_json_lines.is_empty() && malformed_ingests.is_empty();
 
     Ok(ScanResult {
         ok,
         path: path.display().to_string(),
         total_lines,
-        parsed_events,
+        parsed_events: events.len(),
+        ingest_events,
         bad_json_lines,
         malformed_ingests,
-        skipped_ingests,
     })
 }
 
@@ -330,4 +407,25 @@ mod tests {
         assert!(!report.ok);
         assert_eq!(report.bad_json_lines.len(), 1);
     }
+
+    #[test]
+    fn scan_reports_dsl_parse_error_detail() {
+        let dir = tempfile::tempdir().unwrap();
+        let path = dir.path().join("events.jsonl");
+        let ingest = serde_json::json!({
+            "type": "ingest",
+            "ts": 1,
+            "id": "bad-ingest-id",
+            "raw": "{ no closing brace\n~/a { body }\n~/a 1:0 ~/b",
+            "principal": "test",
+            "room_id": "public",
+            "thread_tag": "t",
+        });
+        std::fs::write(&path, format!("{ingest}\n")).unwrap();
+        let report = scan_jsonl(&path).unwrap();
+        assert!(!report.ok);
+        assert_eq!(report.malformed_ingests.len(), 1);
+        assert_eq!(report.malformed_ingests[0].id, "bad-ingest-id");
+        assert!(report.malformed_ingests[0].parse_error.contains("parse error"));
+    }
 }
diff --git a/sorterc/readme.md b/sorterc/readme.md
index 1ebcc3fc935541ea9e47e0458ec67a750fe19fff..e388615c34b40914ca51fc7a79cb738eebc2909b 100644
--- a/sorterc/readme.md
+++ b/sorterc/readme.md
@@ -60,21 +60,33 @@ Rankings use the same structure as the server's dry-run check: parent scope, con
 
 ### `scan` — lint an `events.jsonl`
 
-Reads a JSONL event log and reports problems without starting a server.
+Fast single-pass check. Does **not** replay the log (replay runs rank centrality on every ingest and gets slow fast).
 
 ```bash
 cargo run -p sorterc -- scan events.jsonl
-cargo run -p sorterc -- scan events.jsonl --pretty
+cargo run -p sorterc -- scan events.jsonl --json
+cargo run -p sorterc -- scan events.jsonl --json --pretty
 ```
 
+Default output is human-readable with a blank line between each problem. Use `--json` for machine output.
+
 Reports:
 
 - **bad JSON lines** — lines that are not valid JSON
-- **malformed ingests** — ingest events whose `raw` DSL fails to parse
-- **skipped ingests** — ingests dropped during replay (same behavior as server boot)
+- **malformed ingests** — ingest events whose `raw` DSL fails to parse, with full `parse_error` text
 
 Exits 0 when clean, 1 when any issue is found.
 
+### `compile --ingest` — compile one event from a log
+
+Replay all events **before** the target ingest as base state, then compile that ingest's DSL:
+
+```bash
+cargo run -p sorterc -- compile --ingest cabd8adc-57ae-402d-a940-8e24339ac451 --from events.jsonl --pretty
+```
+
+Output includes `ingest_id`, `ingest_line`, and rankings for that post only. This may take a while for ingests late in a large log (full replay up to that point).
+
 ## Typical uses
 
 - Iterate on `.sorter` files in an editor and pipe through `compile` to see rankings instantly
diff --git a/sorterc/src/main.rs b/sorterc/src/main.rs
index 71382c180085cb0ad71043c852f8db5d3a48a284..5687c850d16973d28749380b8dc89f3bcebbfc56 100644
--- a/sorterc/src/main.rs
+++ b/sorterc/src/main.rs
@@ -22,9 +22,15 @@ struct Cli {
 enum Command {
     /// Parse and simulate a .sorter document; emit ranking JSON to stdout.
     Compile {
-        /// `.sorter` file, or `-` f

… preview truncated; 6,030 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.