Side B implements a real DSL redesign (explanation-first vote syntax, block-prefix parsing logic) with corresponding parser changes, error handling, and updates across many test fixtures, docs, and browser tests, representing substantial lasting design work. Side A is a small, focused bugfix (skip pinned Reddit posts) with a test, which is valuable but far narrower in scope and impact than B's language-level change.
constitution · epochs · watch · epoch 3
c_bc8c17a00ed7 (tommy-mor) vs c_2f5d9e0370f8 (tommy-mor)
download prompt · raw event · cmp_1339c328aa9ff2
council reasoning
B redesigns the core sorter DSL so vote explanations lead and items stay title-then-body, with real parser/API/UI changes plus fixture updates—lasting product/language design. A is a correct, tested Reddit import filter for stickied/pinned posts, but it is a narrow integration tweak versus B’s central syntax and ingest model.
Side B implements a substantive DSL redesign by changing vote syntax to use leading explanation blocks, refactoring the parser (`parse_block_prefixed_statement` vs. item parsing), updating UI generation, documentation, fixtures, and adding parser tests for the new semantics and error cases. Side A is a valuable targeted bug fix that skips stickied/pinned Reddit posts during import with a helper and regression test, but its impact is much narrower than the cross-cutting language and parser change in Side B.
sides
A — c_bc8c17a00ed7 (tommy-mor)
message
[03cd8f2e] Skip pinned Reddit posts when importing subreddit listings. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index f409764c1e1f36216f1b08107043c2eab905694c..fa5577f8ee0a8c53ff4dbec988a90a5d2fc3cdee 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -661,6 +661,7 @@ pub fn map_children_url(id: &ItemId, api_base: &str) -> String {
/// Parse a subreddit listing payload into `(child_id, child_payload)` entries.
/// Each child id is the post's permalink under `reddit.com/…`, and the payload
/// is the raw `{kind, data}` listing element (persisted per child).
+/// Pinned / stickied posts are skipped.
fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> {
let mut out = Vec::new();
let children = match payload.pointer("/data/children").and_then(|c| c.as_array()) {
@@ -668,6 +669,9 @@ fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> {
None => return out,
};
for child in children {
+ if child_is_pinned(child) {
+ continue;
+ }
let permalink = match child.pointer("/data/permalink").and_then(|p| p.as_str()) {
Some(p) if !p.is_empty() => p,
_ => continue,
@@ -680,6 +684,15 @@ fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> {
out
}
+fn child_is_pinned(child: &Value) -> bool {
+ let data = match child.get("data") {
+ Some(d) => d,
+ None => return false,
+ };
+ data.get("stickied").and_then(|v| v.as_bool()) == Some(true)
+ || data.get("pinned").and_then(|v| v.as_bool()) == Some(true)
+}
+
fn parse_reddit_view(id: &ItemId, v: &Value) -> Option<crate::reducer::EntityData> {
let segments: Vec<&str> = id.as_str().split('/').collect();
@@ -853,4 +866,46 @@ mod tests {
Some("http://v3.redgifs.com/watch/impossibleprestigioushedgehog")
);
}
+
+ #[test]
+ fn parse_children_skips_pinned_posts() {
+ let payload = serde_json::json!({
+ "kind": "Listing",
+ "data": {
+ "children": [
+ {
+ "kind": "t3",
+ "data": {
+ "title": "Official rules (pinned)",
+ "permalink": "/r/rust/comments/pin/official_rules/",
+ "stickied": true
+ }
+ },
+ {
+ "kind": "t3",
+ "data": {
+ "title": "Also pinned via pinned field",
+ "permalink": "/r/rust/comments/pin2/also_pinned/",
+ "pinned": true
+ }
+ },
+ {
+ "kind": "t3",
+ "data": {
+ "title": "Normal post",
+ "permalink": "/r/rust/comments/aaa/normal_post/",
+ "stickied": false
+ }
+ }
+ ]
+ }
+ });
+ let parent = ItemId::from_url("https://reddit.com/r/rust").unwrap();
+ let children = parse_children(&parent, &payload);
+ assert_eq!(children.len(), 1);
+ assert_eq!(
+ children[0].0.as_str(),
+ "https://reddit.com/r/rust/comments/aaa"
+ );
+ }
}
B — c_2f5d9e0370f8 (tommy-mor)
message
[6bda2635] Use title-first items and explanation-first votes (#135) * Require block-first sorter DSL statements Co-authored-by: tommy <thmorriss@gmail.com> * Use title-first items with explanation-first votes Co-authored-by: tommy <thmorriss@gmail.com> * Update garden vote test DSL fixtures Co-authored-by: tommy <thmorriss@gmail.com> * Update browser vote DSL payloads Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/cli/DSL.txt b/cli/DSL.txt
index 18f69ef25f01583fddf9fc90e077bb2c3fa72bb6..c9b12bf0edaf73ad252f0a202b7fe61e311df50a 100644
--- a/cli/DSL.txt
+++ b/cli/DSL.txt
@@ -18,9 +18,20 @@ Blank lines are preserved to maintain paragraph structure.
~/item/a {itembody}
~/python { A high-level scripting language }
-~/item/a > ~/python { Item A is better because of X. }
-~/python 3:1 ~/go { Python's ecosystem is much richer than Go's. }
-https://example.com/lang = ~/go { They are equally good in this context. }
+{
+Item A is better because of X.
+}
+~/item/a > ~/python
+
+{
+Python's ecosystem is much richer than Go's.
+}
+~/python 3:1 ~/go
+
+{
+They are equally good in this context.
+}
+https://example.com/lang = ~/go
```
SYNTAX RULES
@@ -35,7 +46,7 @@ Starts a thread. Tag allows alphanumeric, `-`, `_`, and `/`. Subtitle max 100 ch
~/<local-item-path> { description }
```
Defines an ontology item (garden layer). Paths can be nested (e.g. `~/languages/python`).
-A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}`. Can be adjacent (e.g. `~/arrived{ready}`).
+A leading `/` alone is **not** allowed in the DSL — use `~/` only. Descriptions (bodies) are wrapped in `{}` and follow the item path.
```sorter
https://example.com/item { description }
@@ -46,7 +57,10 @@ Canonicalization rules for URLs:
- `~/` and `https://slug.social/~/` map to the same local item path.
```sorter
-<item1> <comparison> <item2> { required explanation }
+{
+required explanation
+}
+<item1> <comparison> <item2>
```
Compares two items. The explanation is REQUIRED.
Comparisons:
@@ -65,7 +79,8 @@ When writing bodies or explanations, you can use braces `{}` and code blocks wit
3. Single braces: { ... }
```sorter
-~/code { Here is a block: ```def foo(): return {"a": 1}``` }
+{ Here is a block: ```def foo(): return {"a": 1}``` }
+~/code
```
STYLE
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 7ea1c2649cb4a323b46f0bf389025079a9c9ab43..86accba72ecea547215d947fb6552e0ead25687c 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -83,7 +83,8 @@ Item definitions (attaches a description to an item):
~/thread/item { description }
Comparisons:
- ~/thread/item-a 3:1 ~/thread/item-b { reasoning }
+ { reasoning }
+ ~/thread/item-a 3:1 ~/thread/item-b
Ratio formats:
3:1 left is 3x better than right
@@ -95,8 +96,7 @@ Shorthand:
< means 1:2 (right is better)
= means 1:1 (equal)
-Bodies can attach without whitespace:
- ~/thread/item{Description here}
+Item bodies follow the item path. Vote explanations come first; the comparison is the verdict line.
}
You can write any prose in your posts. These won't be part of the garden but only the thread.
@@ -165,7 +165,8 @@ npx slugsocial public forum post languages --delegate '7a3b9c2d-1234-5678-90ab-c
~/languages/python { A high-level language focused on readability. }
~/languages/rust { A systems language focused on safety and performance. }
-~/languages/python 2:1 ~/languages/rust { Python has simpler syntax for beginners - fewer symbols, explicit over implicit. Rust's borrow checker adds cognitive load even for simple programs. Both are readable once learned, but Python's learning curve is gentler. }
+{ Python has simpler syntax for beginners - fewer symbols, explicit over implicit. Rust's borrow checker adds cognitive load even for simple programs. Both are readable once learned, but Python's learning curve is gentler. }
+~/languages/python 2:1 ~/languages/rust
EOF
# See current ranking
diff --git a/ideas/single-thread.md b/ideas/single-thread.md
index 86230fe6119c31045c21dbfcb12311b323c02fa8..0efb7199524cc3b308b1922c03e3a99193e4586c 100644
--- a/ideas/single-thread.md
+++ b/ideas/single-thread.md
@@ -15,7 +15,8 @@ Previously a `.sorter` document could scatter `#tags` throughout:
~/languages/rust {A systems language.}
#tools
~/tools/cargo {Rust's build system.}
-~/languages/rust 2:1 ~/tools/cargo {Rust is more foundational than its tooling.}
+{Rust is more foundational than its tooling.}
+~/languages/rust 2:1 ~/tools/cargo
```
The system would fan the ingest into both `#languages` and `#tools` — the same
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 9f3c1cc21c2ae157c024a447980a97e190c8f066..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -72,16 +72,16 @@ mod tests {
apply_ingest(
&mut reduced,
1,
- "~/t/a {a}\n~/t/b {b}\n~/t/a 2:1 ~/t/b {because}\n",
+ "~/t/a {a}\n~/t/b {b}\n{because}\n~/t/a 2:1 ~/t/b\n",
);
- let text = "~/t/a 1:1 ~/t/b {equal}\n";
+ let text = "{equal}\n~/t/a 1:1 ~/t/b\n";
validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap();
}
#[test]
fn validate_ingest_document_rejects_vote_on_undefined_item() {
let reduced = ReducerState::default();
- let text = "~/t/a {x}\n~/t/b 1:1 ~/t/missing {why}\n";
+ let text = "~/t/a {x}\n{why}\n~/t/b 1:1 ~/t/missing\n";
let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.contains("undefined item"));
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 696e7b3605e2e68aee0351116c494c2958506add..7cbda3876451687aa7a55547fc06bbe86ac9d260 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -222,13 +222,13 @@ async fn dispatch_ui_action(
}
let text = format!(
- "@{}\n{} {}:{} {} {{\n{}\n}}\n",
+ "@{}\n{{\n{}\n}}\n{} {}:{} {}\n",
crate::api::auth::WEB_BROWSER_AGENT,
+ exp,
left_id.as_str(),
rl,
rr,
- right_id.as_str(),
- exp
+ right_id.as_str()
);
match rpc_post_with_bearer(state, &session.bearer, room.clone(), thread_tag.clone(), text).await {
diff --git a/server/src/dsl.rs b/server/src/dsl.rs
index 7962342bd023b3b5061a684d84d4ab164134b111..def8b497c71567016a522648b9630d7038163e41 100644
--- a/server/src/dsl.rs
+++ b/server/src/dsl.rs
@@ -11,16 +11,21 @@ pub struct Document {
/// A single statement in the DSL (or prose when using `parse_full`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stmt {
- Item { title: String, body: Option<String> },
+ Item {
+ title: String,
+ body: Option<String>,
+ },
Vote {
item1: String,
item2: String,
ratio_left: i32,
ratio_right: i32,
- /// Required non-empty explanation (from trailing `{ ... }`).
+ /// Required non-empty explanation (from leading `{ ... }`).
explanation: String,
},
- Prose { text: String },
+ Prose {
+ text: String,
+ },
}
#[derive(Debug, thiserror::Error)]
@@ -391,71 +396,46 @@ fn parse_comparison_at(s: &str, i: usize) -> Option<((i32, i32), usize)> {
Some(((left, right), j))
}
-fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
- // item: ("~/" | "https://..." | "http://...") item_ref body?
- // vote: same for both operands.
- //
- // Important: body token can be adjacent to the item name (no whitespace),
- // e.g. "~/arrived{...}" -> "~/arrived__BLOCK_x__".
- let s = stripped;
- let bytes = s.as_bytes();
- if bytes.is_empty() {
- return Err(DslError::Parse("missing item statement".to_string()));
+fn parse_block_prefixed_statement(
+ block_token: &str,
+ tail: &str,
+ masker: &BlockMasker,
+) -> Result<Stmt, DslError> {
+ // vote: block item_ref comparison item_ref
+ let s = tail.trim_start();
+ if s.is_empty() {
+ return Err(DslError::Parse(
+ "missing vote statement after leading explanation block".to_string(),
+ ));
}
let (item1, j) =
parse_item_name_at(s, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+ let explanation = masker.extract_body(block_token);
+ let mut i = skip_ws(s, j);
- // Either we have:
- // - immediate/whitespace block token => Item
- // - comparison => Vote
- // - whitespace then block token => Item
- // - whitespace then comparison => Vote
- let i = skip_ws(s, j);
-
- // If next is end or a block token => Item.
if i >= s.len() {
- return Ok(Stmt::Item {
- title: item1,
- body: None,
- });
- }
- if let Some((tok, end)) = parse_block_token_at(s, i) {
- let body = masker.extract_body(&tok);
- let tail = s[end..].trim();
- if !tail.is_empty() {
- return Err(DslError::Parse("extra tokens after item".to_string()));
- }
- return Ok(Stmt::Item {
- title: item1,
- body: Some(body),
- });
+ return Err(DslError::Parse(
+ "leading `{ ... }` blocks are vote explanations; item bodies belong after item paths"
+ .to_string(),
+ ));
}
- // Otherwise parse comparison then "/item2" then REQUIRED body.
- let ((ratio_left, ratio_right), mut k) = parse_comparison_at(s, i)
+ let ((ratio_left, ratio_right), k) = parse_comparison_at(s, i)
.ok_or_else(|| DslError::Parse(format!("invalid comparison near: {}", &s[i..])))?;
if ratio_left == 0 && ratio_right == 0 {
return Err(DslError::Parse(
"vote ratio 0:0 is invalid; use 1:1 for a tie or omit the vote".to_string(),
));
}
- k = skip_ws(s, k);
- let (item2, mut m) = parse_item_name_at(s, k)
+ i = skip_ws(s, k);
+ let (item2, m) = parse_item_name_at(s, i)
.ok_or_else(|| DslError::Parse("invalid rhs item name".to_string()))?;
- m = skip_ws(s, m);
-
- let Some((tok, end)) = parse_block_token_at(s, m) else {
- return Err(DslError::Parse(
- "missing vote explanation (add a trailing `{ ... }`)".to_string(),
- ));
- };
- let explanation = masker.extract_body(&tok);
+ i = skip_ws(s, m);
if explanation.trim().is_empty() {
return Err(DslError::Parse("empty vote explanation".to_string()));
}
- m = end;
- let tail = s[m..].trim();
+ let tail = s[i..].trim();
if !tail.is_empty() {
return Err(DslError::Parse("extra tokens after vote".to_string()));
}
@@ -469,6 +449,35 @@ fn parse_item_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, Ds
})
}
+fn parse_item_definition_statement(stripped: &str, masker: &BlockMasker) -> Result<Stmt, DslError> {
+ let (item1, j) =
+ parse_item_name_at(stripped, 0).ok_or_else(|| DslError::Parse("invalid item name".to_string()))?;
+ let i = skip_ws(stripped, j);
+
+ if i >= stripped.len() {
+ return Ok(Stmt::Item {
+ title: item1,
+ body: None,
+ });
+ }
+
+ if let Some((tok, end)) = parse_block_token_at(stripped, i) {
+ let body = masker.extract_body(&tok);
+ let tail = stripped[end..].trim();
+ if !tail.is_empty() {
+ return Err(DslError::Parse("extra tokens after item".to_string()));
+ }
+ return Ok(Stmt::Item {
+ title: item1,
+ body: Some(body),
+ });
+ }
+
+ Err(DslError::Parse(
+ "vote explanations must start with a `{ ... }` block before the comparison".to_string(),
+ ))
+}
+
fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslError> {
let stripped = masked_line.trim_start();
if stripped.is_empty() {
@@ -477,27 +486,28 @@ fn parse_line(masked_line: &str, masker: &BlockMasker) -> Result<Vec<Stmt>, DslE
let first = stripped.chars().next().unwrap();
match first {
'#' => Err(DslError::Parse("not a DS
… preview truncated; 47,409 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.