Side A makes a precise, well-tested improvement to the core ranking/pair-selection algorithm used in production voting flow, adding clear sub-priority logic and multiple targeted tests validating the new behavior. Side B adds a useful but peripheral dev-only tool (new crate, CLI, docs) that reuses existing server logic without touching core algorithms, providing convenience rather than fixing or improving the product's central logic.
constitution · epochs · watch · epoch 3
c_2722a3195825 (tommy-mor) vs c_0c466eea1ed4 (tommy-mor)
download prompt · raw event · cmp_ae5a90e940534d
council reasoning
A improves core pair-suggestion logic (bridge sub-priorities and rank-zip refinement) that every vote UX depends on, with concrete tests for attach-over-isolate and adjacent-rank ordering. B adds useful but peripheral offline dev tooling (sorterc + offline.rs) that does not change product ranking behavior.
Side A improves the project's core pair-selection algorithm by introducing structured bridge and within-component prioritization, preferring attachment of isolates to established components and rank-adjacent refinement once the pool is connected, with multiple targeted tests covering the new behavior. Side B adds a valuable offline developer tool (`sorterc`) for compiling `.sorter` files and scanning JSONL logs, but it primarily enhances development workflow rather than changing the application's core ranking behavior.
sides
A — c_2722a3195825 (tommy-mor)
message
[5db58b98] Improve vote pair selection for spanning trees and rank refinement. Prefer attaching unranked items to established components before comparing isolates, then zip down adjacent rank-centrality pairs once the pool is fully connected, skipping pairs that already have votes. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/pair.rs b/server/src/pair.rs
index 54b5d2417e9dba04ed8df422156e274c2b2f76b2..c14de4b0502c8b5a17cddf3746077739d56e03e0 100644
--- a/server/src/pair.rs
+++ b/server/src/pair.rs
@@ -3,13 +3,21 @@
//! Pair selection prefers **bridge** votes — comparisons between items in
//! different connected components of the voted-pairs graph — so the pool
//! merges into one ranking group before refining within it.
+//!
+//! Among unvoted bridges, prefer merging established voted components, then
+//! attaching a never-voted child to an established component, and only then
+//! comparing two never-voted children (so the voted graph grows as one tree).
+//!
+//! Once every pool child sits in one voted component, refinement **zips** down
+//! the rank-centrality order: prefer 1 vs 2, then 2 vs 3, and so on, skipping
+//! pairs that already have a vote.
use rand::seq::SliceRandom;
use std::collections::{HashMap, HashSet};
use crate::{
path_types::ItemId,
- ranking::connected_components_from_voted_pairs,
+ ranking::{connected_components_from_voted_pairs, ranked_items},
reducer::{GlobalTree, GroupState},
};
@@ -28,36 +36,77 @@ fn pair_is_voted(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {
group.voted_pairs.contains(&(i, j))
}
-/// Component id per pool item: voted-pairs graph components plus one id per
-/// never-voted child.
-fn component_ids(group: &GroupState, pool: &[ItemId]) -> HashMap<ItemId, usize> {
+/// Voted-pairs layout for pool items: component id per item plus which ids are
+/// multi-node voted components (ranked groups in the UI).
+struct ComponentLayout {
+ ids: HashMap<ItemId, usize>,
+ established: HashSet<usize>,
+}
+
+fn component_layout(group: &GroupState, pool: &[ItemId]) -> ComponentLayout {
let n = group.idx_to_item.len();
let (comps, isolates) =
connected_components_from_voted_pairs(n, group.voted_pairs.iter().copied());
- let mut out: HashMap<ItemId, usize> = HashMap::new();
+ let mut established = HashSet::new();
+ let mut ids: HashMap<ItemId, usize> = HashMap::new();
for (comp_idx, comp) in comps.iter().enumerate() {
+ if comp.len() >= 2 {
+ established.insert(comp_idx);
+ }
for &idx in comp {
if idx < n {
- out.insert(group.idx_to_item[idx].clone(), comp_idx);
+ ids.insert(group.idx_to_item[idx].clone(), comp_idx);
}
}
}
let mut next = comps.len();
for &idx in &isolates {
if idx < n {
- out.insert(group.idx_to_item[idx].clone(), next);
+ ids.insert(group.idx_to_item[idx].clone(), next);
next += 1;
}
}
for item in pool {
- out.entry(item.clone()).or_insert_with(|| {
+ ids.entry(item.clone()).or_insert_with(|| {
let id = next;
next += 1;
id
});
}
- out
+ ComponentLayout { ids, established }
+}
+
+/// Every pool child shares one multi-node voted component (spanning tree phase done).
+fn pool_fully_connected(layout: &ComponentLayout, pool: &[ItemId]) -> bool {
+ if pool.len() < 2 {
+ return false;
+ }
+ let mut comp_id = None;
+ for item in pool {
+ let Some(id) = layout.ids.get(item) else {
+ return false;
+ };
+ if !layout.established.contains(id) {
+ return false;
+ }
+ match comp_id {
+ None => comp_id = Some(*id),
+ Some(expected) if expected == *id => {}
+ _ => return false,
+ }
+ }
+ comp_id.is_some()
+}
+
+/// Pool children that appear in `group`, sorted best rank first.
+fn ranked_pool_order(group: &GroupState, pool: &[ItemId]) -> Vec<ItemId> {
+ let pool_set: HashSet<_> = pool.iter().collect();
+ ranked_items(group)
+ .into_iter()
+ .map(|r| r.item)
+ .filter(|id| pool_set.contains(id))
+ .collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@@ -72,19 +121,108 @@ enum PairPriority {
WithinVoted = 3,
}
-fn pair_priority(
+/// Tie-break among unvoted bridge pairs.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+enum BridgeSubPriority {
+ /// Both endpoints lie in established (multi-node) voted components.
+ MergeEstablished = 0,
+ /// One established component member and one never-voted child.
+ AttachIsolate = 1,
+ /// Two never-voted children (separate singleton components).
+ IsolatePair = 2,
+}
+
+/// Tie-break among within-component pairs once the pool is one connected group.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+struct WithinSubPriority {
+ /// 1 = adjacent ranks (i vs i+1); larger = farther apart in the order.
+ rank_gap: usize,
+ /// min rank index of the two — zip from the top (1 vs 2 before 2 vs 3).
+ zip_index: usize,
+}
+
+const WITHIN_SUB_WORST: WithinSubPriority = WithinSubPriority {
+ rank_gap: usize::MAX,
+ zip_index: usize::MAX,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+struct PairSortKey {
+ priority: PairPriority,
+ bridge_sub: BridgeSubPriority,
+ within_sub: WithinSubPriority,
+}
+
+fn item_in_established(layout: &ComponentLayout, item: &ItemId) -> bool {
+ layout
+ .ids
+ .get(item)
+ .is_some_and(|id| layout.established.contains(id))
+}
+
+fn bridge_sub_priority(layout: &ComponentLayout, a: &ItemId, b: &ItemId) -> BridgeSubPriority {
+ let a_est = item_in_established(layout, a);
+ let b_est = item_in_established(layout, b);
+ match (a_est, b_est) {
+ (true, true) => BridgeSubPriority::MergeEstablished,
+ (true, false) | (false, true) => BridgeSubPriority::AttachIsolate,
+ (false, false) => BridgeSubPriority::IsolatePair,
+ }
+}
+
+fn within_sub_priority(
+ group: &GroupState,
+ pool: &[ItemId],
+ layout: &ComponentLayout,
+ a: &ItemId,
+ b: &ItemId,
+) -> WithinSubPriority {
+ if !pool_fully_connected(layout, pool) {
+ return WITHIN_SUB_WORST;
+ }
+ let order = ranked_pool_order(group, pool);
+ let (Some(i), Some(j)) = (order.iter().position(|x| x == a), order.iter().position(|x| x == b))
+ else {
+ return WITHIN_SUB_WORST;
+ };
+ WithinSubPriority {
+ rank_gap: i.abs_diff(j),
+ zip_index: i.min(j),
+ }
+}
+
+fn pair_sort_key(
group: &GroupState,
- components: &HashMap<ItemId, usize>,
+ pool: &[ItemId],
+ layout: &ComponentLayout,
a: &ItemId,
b: &ItemId,
-) -> PairPriority {
+) -> PairSortKey {
let voted = pair_is_voted(group, a, b);
- let bridge = components.get(a) != components.get(b);
- match (bridge, voted) {
+ let bridge = layout.ids.get(a) != layout.ids.get(b);
+ let priority = match (bridge, voted) {
(true, false) => PairPriority::BridgeUnvoted,
(false, false) => PairPriority::WithinUnvoted,
(true, true) => PairPriority::BridgeVoted,
(false, true) => PairPriority::WithinVoted,
+ };
+ let bridge_sub = if priority == PairPriority::BridgeUnvoted {
+ bridge_sub_priority(layout, a, b)
+ } else {
+ BridgeSubPriority::MergeEstablished
+ };
+ let within_sub = if matches!(
+ priority,
+ PairPriority::WithinUnvoted | PairPriority::WithinVoted
+ ) {
+ within_sub_priority(group, pool, layout, a, b)
+ } else {
+ WITHIN_SUB_WORST
+ };
+ PairSortKey {
+ priority,
+ bridge_sub,
+ within_sub,
}
}
@@ -109,9 +247,12 @@ fn candidate_pairs(pool: &[ItemId], exclude: Option<(&ItemId, &ItemId)>) -> Vec<
/// Pick the next pair to vote on within `pool`.
///
-/// 1. Prefer unvoted **bridge** pairs (connect separate ranking components).
-/// 2. Then unvoted within-component pairs (refinement).
-/// 3. Then already-voted pairs (re-compare).
+/// 1. Prefer unvoted **bridge** pairs (connect separate ranking components),
+/// with sub-priority: merge established components, attach an isolate to
+/// established, then compare two isolates.
+/// 2. Then unvoted within-component pairs; when the pool is one connected group,
+/// prefer adjacent ranks (1 vs 2, 2 vs 3, …) in order, skipping voted pairs.
+/// 3. Then already-voted pairs (re-compare), with the same zip ordering.
pub fn suggest_next_pair_in_pool(
group: &GroupState,
pool: &[ItemId],
@@ -121,15 +262,15 @@ pub fn suggest_next_pair_in_pool(
if candidates.is_empty() {
return None;
}
- let components = component_ids(group, pool);
+ let layout = component_layout(group, pool);
let best = candidates
.iter()
- .map(|(a, b)| (pair_priority(group, &components, a, b), (a, b)))
- .min_by_key(|(p, _)| *p)?
+ .map(|(a, b)| (pair_sort_key(group, pool, &layout, a, b), (a, b)))
+ .min_by_key(|(k, _)| *k)?
.0;
let best_pairs: Vec<(ItemId, ItemId)> = candidates
.into_iter()
- .filter(|(a, b)| pair_priority(group, &components, a, b) == best)
+ .filter(|(a, b)| pair_sort_key(group, pool, &layout, a, b) == best)
.collect();
best_pairs.choose(&mut rand::thread_rng()).cloned()
}
@@ -303,6 +444,38 @@ mod tests {
assert!(from_ab && from_cd, "expected bridge pair, got {:?}", chosen);
}
+ #[test]
+ fn suggest_prefers_attach_over_isolate_pair_among_many_unranked() {
+ let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+ let mut tree = seed_children(
+ &parent,
+ &[
+ "reddit.com/r/rust/a",
+ "reddit.com/r/rust/b",
+ "reddit.com/r/rust/c",
+ "reddit.com/r/rust/d",
+ "reddit.com/r/rust/e",
+ ],
+ );
+ let ab =
+ VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap();
+ tree.apply_vote(&parent, ab);
+ let group = tree.get(&parent).unwrap().local_ranking.clone();
+ let pool = children_of(&tree, &parent);
+ let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
+ let chosen = pair_set(&pair);
+ let from_ab =
+ chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b");
+ let from_cde = chosen.contains("reddit.com/r/rust/c")
+ || chosen.contains("reddit.com/r/rust/d")
+ || chosen.contains("reddit.com/r/rust/e");
+ assert!(
+ from_ab && from_cde,
+ "expected ranked+unranked attach, got {:?}",
+ chosen
+ );
+ }
+
#[test]
fn suggest_connects_isolate_to_existing_component() {
let parent = ItemId::parse("reddit.com/r/rust").unwrap();
@@ -325,6 +498,65 @@ mod tests {
assert!(chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b"));
}
+ #[test]
+ fn suggest_zips_adjacent_ranks_when_tree_complete() {
+ let parent = ItemId::parse("reddit.com/r/rust").unwrap();
+ let mut tree = seed_children(
+ &parent,
+ &[
+ "reddit.com/r/rust/a",
+ "reddit.com/r/rust/b",
+ "reddit.com/r/rust/c",
+ ],
+ );
+ // Star at a connects all three; b-c is the only unvoted adjacent pair left.
+ for (a, b, l, r) in [
+ ("reddit.com/r/rust/a", "reddit.com/r/rust/b", 3, 1),
+ ("reddit.com/r/rust/a", "reddit.com/r/rust/c", 2, 1),
+ ] {
+ let v = VoteData::from_recorded(1, a, b, l, r).unwrap();
+ tree.apply_vote(&parent, v);
+ }
+ let group = tree.get(&parent).unwrap().local_ranking.clone();
+ let pool = children_of(&tree, &parent);
+ let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
+ let chosen = pair_set(&pair);
+ // a-b and a-c voted;
… preview truncated; 1,687 characters omittedB — c_0c466eea1ed4 (tommy-mor)
message
[9e20d06c] Add sorterc dev tool for offline DSL compile and JSONL lint. Introduce a workspace-only binary that validates .sorter files into ranking JSON and scans events.jsonl for corrupt or unreplayable ingests. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index bf8153d9c723af97122c9ffdd4a7cfe82e853bb6..a07734f089b466440c3ae6fc1087ce85fc24ce62 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1826,6 +1826,17 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "sorterc"
+version = "0.0.1"
+dependencies = [
+ "anyhow",
+ "clap",
+ "serde",
+ "serde_json",
+ "slugsocial-server",
+]
+
[[package]]
name = "spin"
version = "0.9.8"
diff --git a/Cargo.toml b/Cargo.toml
index 149cbf07901eab57c593184ff8719a75d530f1da..25337acdd61e44b20f354c78fed4a88caf896280 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = ["server", "cli"]
+members = ["server", "cli", "sorterc"]
resolver = "2"
diff --git a/agents.md b/agents.md
index d8b801e454fdf37e7ac6038b91a69f83b0746d59..ce646ed3cd7123be4732dccec4a6800467e651e7 100644
--- a/agents.md
+++ b/agents.md
@@ -93,6 +93,17 @@ SLUG_GOOGLE_CLIENT_SECRET=mock
After OAuth completes, the pending-session poll returns a `slug_…` bearer token for API calls.
+### Dev-only offline tooling
+
+**`sorterc`** — workspace binary, not published via npm. Compiles `.sorter` files and lints `events.jsonl` without a server:
+
+```
+cargo run -p sorterc -- compile path/to/doc.sorter [--base events.jsonl] [--room public] [--pretty]
+cargo run -p sorterc -- scan path/to/events.jsonl [--pretty]
+```
+
+`compile` validates DSL, simulates ingest against empty (or `--base`) reducer state, and prints JSON rankings. `scan` reports corrupt JSONL lines and ingests that fail DSL replay.
+
### Testing
- **Rust tests:** `cargo nextest run --workspace` (163 tests; requires `cargo-nextest`)
diff --git a/server/src/lib.rs b/server/src/lib.rs
index c1d477d21aea03aff00e6f0689b0b4379d0d68d2..ad8e31099c807fb5844acb16cd5086a2f19327a7 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -10,6 +10,7 @@ pub mod form_template;
pub mod html;
pub mod identity;
pub mod middleware;
+pub mod offline;
pub mod path_types;
pub mod ranking;
pub mod reducer;
diff --git a/server/src/offline.rs b/server/src/offline.rs
new file mode 100644
index 0000000000000000000000000000000000000000..54ad0ded096a305ef8454ab2cdd1c3af71b14f5d
--- /dev/null
+++ b/server/src/offline.rs
@@ -0,0 +1,333 @@
+//! Offline `.sorter` compilation and JSONL diagnostics (no network, no auth).
+
+use std::collections::HashSet;
+use std::path::Path;
+
+use serde::Serialize;
+use slug_types::{CheckScopeRanking, RankComponent, RankRow, paths::GardenItemUrl};
+
+use crate::{
+ api::{resolve_item, validate_ingest_document},
+ dsl,
+ events::{Event, Ingest},
+ path_types::ItemId,
+ reducer::{ReducerState, ScopeId, scope_from_room_wire},
+ scope_rank::build_children_rankings,
+};
+
+#[derive(Debug, Clone, Serialize)]
+pub struct CompileStats {
+ pub items: usize,
+ pub votes: usize,
+ pub prose_blocks: usize,
+}
+
+#[derive(Debug, Serialize)]
+pub struct CompileResult {
+ pub ok: bool,
+ pub threads: Vec<String>,
+ pub rankings: Vec<CheckScopeRanking>,
+ pub stats: CompileStats,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct CompileError {
+ pub ok: bool,
+ pub error: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub hint: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct BadJsonLine {
+ pub line: usize,
+ pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct MalformedIngest {
+ pub line: usize,
+ pub id: String,
+ pub room_id: String,
+ pub thread_tag: String,
+ pub reason: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ScanResult {
+ pub ok: bool,
+ pub path: String,
+ pub total_lines: usize,
+ pub parsed_events: usize,
+ pub bad_json_lines: Vec<BadJsonLine>,
+ pub malformed_ingests: Vec<MalformedIngest>,
+ pub skipped_ingests: usize,
+}
+
+fn document_stats(doc: &dsl::Document) -> CompileStats {
+ let mut items = 0usize;
+ let mut votes = 0usize;
+ let mut prose_blocks = 0usize;
+ for stmt in &doc.statements {
+ match stmt {
+ dsl::Stmt::Item { .. } => items += 1,
+ dsl::Stmt::Vote { .. } => votes += 1,
+ dsl::Stmt::Prose { .. } => prose_blocks += 1,
+ }
+ }
+ CompileStats {
+ items,
+ votes,
+ prose_blocks,
+ }
+}
+
+fn threads_in_document(text: &str) -> Vec<String> {
+ let mut out = HashSet::new();
+ for line in text.lines() {
+ let trimmed = line.trim();
+ if !trimmed.starts_with('#') {
+ continue;
+ }
+ let rest = trimmed.trim_start_matches('#').trim();
+ if rest.is_empty() {
+ continue;
+ }
+ let tag = rest.split_whitespace().next().unwrap_or(rest);
+ let tag = tag.split(':').next().unwrap_or(tag).trim();
+ if tag.is_empty() {
+ continue;
+ }
+ out.insert(format!("#{}", crate::canonical_path::canonicalize_tag(tag)));
+ }
+ let mut tags: Vec<String> = out.into_iter().collect();
+ tags.sort();
+ tags
+}
+
+fn voted_parent_scopes(doc: &dsl::Document) -> Vec<ItemId> {
+ let mut parents = HashSet::new();
+ for stmt in &doc.statements {
+ if let dsl::Stmt::Vote { item1, item2, .. } = stmt {
+ if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) {
+ if let Some(p) = a.parent() {
+ parents.insert(p);
+ }
+ if let Some(p) = b.parent() {
+ parents.insert(p);
+ }
+ }
+ }
+ }
+ let mut out: Vec<ItemId> = parents.into_iter().collect();
+ out.sort();
+ out
+}
+
+fn rankings_for_simulated(
+ simulated: &ReducerState,
+ scope: &ScopeId,
+ room_wire: &str,
+ doc: &dsl::Document,
+) -> Vec<CheckScopeRanking> {
+ voted_parent_scopes(doc)
+ .iter()
+ .map(|parent| {
+ let scoped_content = simulated
+ .content_for_scope(&scope)
+ .unwrap_or_else(|| simulated.public());
+ let scoped = build_children_rankings(scoped_content, parent);
+ let components: Vec<RankComponent> = scoped
+ .component_rankings
+ .into_iter()
+ .map(|comp| RankComponent {
+ pairs: comp.pairs,
+ ranking: comp
+ .ranked
+ .into_iter()
+ .map(|r| RankRow {
+ item: GardenItemUrl::from_stored(&r.item, room_wire),
+ score: r.score,
+ percent: None,
+ })
+ .collect(),
+ })
+ .collect();
+ CheckScopeRanking {
+ parent: GardenItemUrl::from_stored(parent, room_wire).into_inner(),
+ components,
+ unranked_items: scoped
+ .unranked_items
+ .into_iter()
+ .map(|it| GardenItemUrl::from_stored(&it, room_wire))
+ .collect(),
+ }
+ })
+ .collect()
+}
+
+/// Validate and simulate one `.sorter` document against optional base reducer state.
+pub fn compile_document(
+ base: &ReducerState,
+ room: &str,
+ text: &str,
+) -> 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)| {
+ CompileError {
+ ok: false,
+ error: message,
+ hint,
+ }
+ })?;
+
+ let event = Event::Ingest(Ingest {
+ ts: validated.ts,
+ id: uuid::Uuid::new_v4().to_string(),
+ raw: validated.raw_text.clone(),
+ principal: "offline".to_string(),
+ delegate: None,
+ room_id: room_key.to_string(),
+ thread_tag: "offline".to_string(),
+ });
+
+ let mut simulated = base.clone();
+ simulated.apply_event(event);
+
+ Ok(CompileResult {
+ ok: true,
+ threads: threads_in_document(text),
+ rankings: rankings_for_simulated(&simulated, &scope, room_key, &validated.doc),
+ stats: document_stats(&validated.doc),
+ })
+}
+
+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> {
+ let text = std::fs::read_to_string(path)?;
+ let mut events = Vec::new();
+ let mut bad_json_lines = Vec::new();
+ for (idx, line) in text.lines().enumerate() {
+ let line_no = idx + 1;
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ continue;
+ }
+ match serde_json::from_str::<Event>(trimmed) {
+ Ok(ev) => events.push((line_no, ev)),
+ Err(e) => bad_json_lines.push(BadJsonLine {
+ line: line_no,
+ message: e.to_string(),
+ }),
+ }
+ }
+ Ok((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)?;
+ let mut state = ReducerState::default();
+ for (_line_no, ev) in events {
+ state.apply_event(ev);
+ }
+ Ok((state, bad_json_lines))
+}
+
+/// 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)?;
+
+ let mut malformed_ingests = Vec::new();
+ let mut skipped_ingests = 0usize;
+ let mut state = ReducerState::default();
+ let parsed_events = events.len();
+
+ for (line_no, ev) in events {
+ if let Event::Ingest(ref ing) = ev {
+ if let Some(reason) = ingest_parse_error(&ing.raw) {
+ malformed_ingests.push(MalformedIngest {
+ line: line_no,
+ id: ing.id.clone(),
+ room_id: ing.room_id.clone(),
+ thread_tag: ing.thread_tag.clone(),
+ reason,
+ });
+ }
+ 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;
+
+ Ok(ScanResult {
+ ok,
+ path: path.display().to_string(),
+ total_lines,
+ parsed_events,
+ bad_json_lines,
+ malformed_ingests,
+ skipped_ingests,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const TUTORIAL: &str = include_str!("../tests/fixtures/tutorial.sorter");
+
+ #[test]
+ fn compile_tutorial_fixture_emits_rankings() {
+ let result = compile_document(&ReducerState::default(), "public", TUTORIAL).unwrap();
+ assert!(result.ok);
+ assert!(!result.threads.is_empty());
+ assert!(result.stats.items >= 6);
+ assert!(result.stats.votes >= 6);
+ assert!(!result.rankings.is_empty());
+ }
+
+ #[test]
+ fn compile_rejects_vote_on_missing_item() {
+ let err = compile_document(
+ &ReducerState::default(),
+ "public",
+ "{ reason }\n~/missing/a 2:1 ~/missing/b",
+ )
+ .unwrap_err();
+ assert!(!err.ok);
+ assert!(err.error.contains("undefined"));
+ }
+
+ #[test]
+ fn scan
… preview truncated; 7,950 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.