You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [0728c06a] Vote pool: use display_path in hrefs; test all 45 pairs + assert ranking. - vote_compare_href and vote_pool_href now encode ~/… and -/… as their short display forms (not the full https://slug.social/… storage URL), matching what users see in the item display and DSL. - Rewrite browser_vote_pool test to vote all C(10,2)=45 pairs in the pool, always preferring the alphabetically-earlier letter, then query GetGardenRank and assert the 10 items form one component ranked a→j. Co-Authored-By: Claude Sonnet 4.6 Side A — unified diff (full patch): diff --git a/server/src/html/garden/vote.rs b/server/src/html/garden/vote.rs index 2682cfcbd2f834b56459a02831aa225cffe67c58..d0ec78cd675eae284d056fb3b8eaf5cc853d6263 100644 --- a/server/src/html/garden/vote.rs +++ b/server/src/html/garden/vote.rs @@ -234,8 +234,10 @@ pub(super) fn vote_compare_href( thread_override: Option<&str>, pool: Option<&ItemId>, ) -> String { - let left_q = urlencoding::encode(left.as_str()); - let right_q = urlencoding::encode(right.as_str()); + let left_dp = left.display_path(); + let right_dp = right.display_path(); + let left_q = urlencoding::encode(&left_dp); + let right_q = urlencoding::encode(&right_dp); let mut base = format!( "{}/vote?left={}&right={}", nav.room_path_prefix_for_vote_compare(), @@ -246,16 +248,20 @@ pub(super) fn vote_compare_href( base = format!("{}&thread={}", base, urlencoding::encode(t)); } if let Some(p) = pool { - base = format!("{}&pool={}", base, urlencoding::encode(p.as_str())); + let pool_dp = p.display_path(); + base = format!("{}&pool={}", base, urlencoding::encode(&pool_dp)); } base } pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String { + let display = ItemId::parse(pool_item_str) + .map(|i| i.display_path()) + .unwrap_or_else(|| pool_item_str.to_string()); format!( "{}/vote?pool={}", nav.room_path_prefix_for_vote_compare(), - urlencoding::encode(pool_item_str) + urlencoding::encode(&display) ) } diff --git a/test/browser_vote_pool.clj b/test/browser_vote_pool.clj index d51f05db47dc3cb013e56e65f3a1edfbbcbd96ab..23d0bd80b02bd8b1b48853454bed02793296550e 100644 --- a/test/browser_vote_pool.clj +++ b/test/browser_vote_pool.clj @@ -1,6 +1,8 @@ (ns test.browser-vote-pool - "Pool-scoped voting: seed ~/pool/a-j, enter via /vote?pool=~/pool, follow - the vote → next-pair → vote sequence until no next pair or 15 iterations." + "Pool-scoped voting: seed ~/pool/a-j (10 letters), follow the + vote → next-pair sequence for all C(10,2)=45 pairs voting the + alphabetically-earlier item each time, then assert the garden + ranking is a…j in order." (:require [babashka.fs :as fs] [cheshire.core :as json] [clojure.string :as str] @@ -11,26 +13,38 @@ [test.common :as common] [test.oauth :as oauth])) +(def letters ["a" "b" "c" "d" "e" "f" "g" "h" "i" "j"]) +(def total-pairs (/ (* (count letters) (dec (count letters))) 2)) ; C(10,2) = 45 + (defn- wait-for-text [pg selector expected timeout-ms] (let [deadline (+ (System/currentTimeMillis) timeout-ms)] (loop [] - (let [text (locator/text-content (page/locator pg selector))] + (let [text (try (locator/text-content (page/locator pg selector)) (catch Exception _ nil))] (if (and (string? text) (str/includes? text expected)) true (if (< (System/currentTimeMillis) deadline) - (do (Thread/sleep 200) (recur)) + (do (Thread/sleep 150) (recur)) false)))))) (defn- element-text [pg selector] - (try (locator/text-content (page/locator pg selector)) (catch Exception _ nil))) + (try (locator/text-content (page/locator pg selector)) (catch Exception _ ""))) (defn- enc [^String s] (java.net.URLEncoder/encode s "UTF-8")) -(def letters ["a" "b" "c" "d" "e" "f" "g" "h" "i" "j"]) +;; Extract the terminal path segment, e.g. "~/pool/c" → "c". +(defn- leaf [path] (last (str/split path #"/"))) + +;; Set the hidden ratio inputs so the alphabetically-earlier item wins. +(defn- set-ratio! [pg left-text right-text] + (let [[rl rr] (if (neg? (compare (leaf left-text) (leaf right-text))) + [100 0] ; left is earlier → prefer left + [0 100])] ; right is earlier → prefer right + (page/evaluate pg (str "document.getElementById('vote-ratio-left').value='" rl "'")) + (page/evaluate pg (str "document.getElementById('vote-ratio-right').value='" rr "'")))) (defn vote-pool-flow! [] - (println "\n━━━ browser vote pool (/vote?pool= seeds + follow next-pair sequence) ━━━\n") + (println (str "\n━━━ browser vote pool (all " total-pairs " pairs → sorted ranking) ━━━\n")) (common/letlocals (bind build (common/run-cargo-build-release! ["slugsocial-server"])) @@ -54,7 +68,6 @@ (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice") thread-tag "browser-vote-pool" - ;; seed ~/pool/a through ~/pool/j as items with bodies item-lines (str/join "\n" (map (fn [l] (str "~/pool/" l " {" l "}")) letters)) raw (str "# " thread-tag "\n\n~/pool {root}\n" item-lines "\n") @@ -77,51 +90,57 @@ (page/navigate pg (str base-url "/login")) (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") - ;; Enter via pool URL — page picks first pair automatically. (page/navigate pg pool-url) (is (wait-for-text pg "body.view-vote-compare" "compare" 15000) "pool entry: vote compare page loads") - ;; Verify the initial pair is within the pool. - (let [pair-text (element-text pg ".vote-compare-pair")] - (is (and (string? pair-text) (str/includes? pair-text "~/pool/")) - (str "initial pair is within ~/pool: " pair-text))) - - ;; Follow vote → next-pair sequence up to 15 iterations. - (let [votes-cast - (loop [i 0] - (if (>= i 15) - i - (let [explanation (str "pool vote " i " reason")] - (locator/fill (page/locator pg "#vote-explain") explanation) - (locator/click (page/locator pg "#vote-compare-form button[type=submit]")) - ;; Wait for edge history morph confirming the vote landed. - (if-not (wait-for-text pg "ul.vote-edge-history" explanation 20000) - (do (println " vote" i "history morph timed out — stopping") - i) - (let [has-next (wait-for-text pg "[data-testid=\"vote-next-pair\"]" - "next pair" 8000)] - (if-not has-next - ;; "no next pair" — pool exhausted. - (do (println " no next pair after vote" i " — pool exhausted") - (inc i)) - (do - ;; Verify the pair on this page is within the pool before advancing. - (let [pt (element-text pg ".vote-compare-pair")] - (is (and (string? pt) (str/includes? pt "~/pool/")) - (str "pair at vote " i " is within ~/pool: " pt))) - (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]")) - ;; Wait for next pair to load. - (wait-for-text pg "body.view-vote-compare" "compare" 10000) - (recur (inc i)))))))))] - - (is (>= votes-cast 1) (str "cast at least 1 vote, got: " votes-cast)) - (println (str " pool voting sequence complete: " votes-cast " vote(s) cast"))) - - ;; After the sequence, the current page is still a pool-scoped vote page. - (let [url (page/url pg)] - (is (str/includes? (or url "") "/vote") - (str "still on /vote after sequence: " url)))))))) + ;; Vote all 45 pairs, always preferring the alphabetically-earlier item. + (loop [votes-cast 0] + (when (< votes-cast total-pairs) + (let [left-text (element-text pg ".vote-compare-left code") + right-text (element-text pg ".vote-compare-right code")] + (is (str/includes? left-text "~/pool/") + (str "vote " votes-cast ": left is in pool: " left-text)) + (is (str/includes? right-text "~/pool/") + (str "vote " votes-cast ": right is in pool: " right-text)) + (set-ratio! pg left-text right-text) + (let [winner (if (neg? (compare (leaf left-text) (leaf right-text))) + (leaf left-text) (leaf right-text))] + (locator/fill (page/locator pg "#vote-explain") + (str "prefer " winner))) + (locator/click (page/locator pg "#vote-compare-form button[type=submit]")) + (is (wait-for-text pg "ul.vote-edge-history" "prefer " 20000) + (str "vote " votes-cast " appears in edge history")) + (when (< (inc votes-cast) total-pairs) + (is (wait-for-text pg "[data-testid=\"vote-next-pair\"]" "next pair" 8000) + (str "next pair available after vote " votes-cast)) + (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]")) + (is (wait-for-text pg "body.view-vote-compare" "compare" 10000) + (str "vote page loaded for pair " (inc votes-cast)))) + (recur (inc votes-cast))))) + + (println (str " cast all " total-pairs " votes")) + + ;; Query the ranking via RPC and assert alphabetical order. + (let [rank-resp (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"GetGardenRank" {"room" "public" + "parent_path" "~/pool"}}] + :headers {"Authorization" (str "Bearer " alice-token)}) + rank-json (json/parse-string (:body rank-resp) true) + result (get-in rank-json [:results 0 :result :GardenRank]) + components (:components result) + unranked (:unranked_items result) + ranked (mapv :item (mapcat :ranking components)) + ranked-leaves (mapv #(last (str/split % #"[/~]+")) ranked)] + (is (= 1 (count components)) + (str "all 10 items form one connected component (got " (count components) ")")) + (is (empty? unranked) + (str "no unranked items (got " (count unranked) ")")) + (is (= 10 (count ranked)) + (str "10 items ranked (got " (count ranked) ")")) + (is (= letters ranked-leaves) + (str "ranking is alphabetical a→j (got " ranked-leaves ")")))))))) (finally (when-some [s @!server] (common/kill-server s)) Side B — contributor: tommy-mor Side B — commit message: [993d359c] Fix Reddit children import wiring and unranked child labels. Listing imports attach posts directly under the subreddit without ensure_path pulling comment-path segments in, and the ranking panel shows imported titles. Update integration tests for JS SSE morphs and children fetch. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index d1defd28242fd2ca3b886adc91a7070bef75e653..e649a7d192feade465e19ce6187a829f6ec74372 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -58,7 +58,7 @@ pub async fn post_ui_html( let tree = state.tree.read().await; let empty = crate::reducer::NodeState::default(); let node = tree.get(&parent).unwrap_or(&empty); - let panel = ranking_panel(&parent, node); + let panel = ranking_panel(&parent, node, &tree); JsBuilder::new() .morph_selector("#ranking-panel", panel) .into_response() diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs index 0177bb161cea1b72a100b52efdfc5e710271c9eb..35f968c21c69ca557bab7951413e3cfbbccfebfd 100644 --- a/server/src/fetch/mod.rs +++ b/server/src/fetch/mod.rs @@ -107,7 +107,7 @@ pub fn fetch_entity_stream( let mut b = JsBuilder::new() .morph_selector("#entity-section", html::entity_section(&id, node, false)); if kind == FetchKind::Children { - b = b.morph_selector("#ranking-panel", ranking_panel(&id, node)); + b = b.morph_selector("#ranking-panel", ranking_panel(&id, node, &tree)); } yield Ok(js_event(b.build())); } diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 27ce9118c73ec5643e04363ab1a36cf5da6101bf..e88cc43ddc9d8100f7994be6f5960ec4d8f22c55 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -15,7 +15,7 @@ use crate::{ ranking::{ connected_components_from_voted_pairs, ranked_items_subset, RankedItem, MAX_ITERS, TOL, }, - reducer::NodeState, + reducer::{GlobalTree, NodeState}, state::AppState, ui_action::UI_RPC_FIELD, }; @@ -182,8 +182,15 @@ fn display_label(id: &ItemId) -> String { .to_string() } +fn child_label(tree: &GlobalTree, id: &ItemId) -> String { + tree.get(id) + .and_then(|n| n.data.as_ref()) + .map(|d| d.title.clone()) + .unwrap_or_else(|| display_label(id)) +} + /// Plain (unscored) list of children that have no votes yet. -fn unranked_list(label: &str, items: &[ItemId]) -> Markup { +fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup { html! { @if !items.is_empty() { h3 class="rank-heading muted small" { (label) } @@ -191,7 +198,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup { @for it in items { li { a href=(item_href(it)) { - strong { (display_label(it)) } + strong { (child_label(tree, it)) } } } } @@ -200,7 +207,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup { } } -pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup { +pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Markup { let group = &node.local_ranking; let n = group.idx_to_item.len(); let (comps, _isolates) = @@ -248,7 +255,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup { @let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() }; (rank_list(&label, ranked, 1)) } - (unranked_list("Unranked", &unranked)) + (unranked_list("Unranked", &unranked, tree)) } } } @@ -297,7 +304,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup { (input_panel("", None)) (breadcrumb_path(&item)) (entity_section(&item, node, false)) - (ranking_panel(&item, node)) + (ranking_panel(&item, node, &tree)) }; layout("sorter2", body, views) } diff --git a/server/src/reddit.rs b/server/src/reddit.rs index a0eb688709478ee0185b953b41a5d26cc354764d..69d979bc7e4a1cb078f70114dc539bdc1986b574 100644 --- a/server/src/reddit.rs +++ b/server/src/reddit.rs @@ -300,9 +300,16 @@ async fn reddit_worker( } { let mut tree = tree.write().await; - apply_entity_import(&mut tree, &child_id, child_payload); if kind == FetchKind::Children { - tree.link_child(&fetch_id, &child_id); + let view = entity_view_from_payload(&child_id, &child_payload); + tree.apply_entity_under_parent( + &fetch_id, + &child_id, + child_payload, + view, + ); + } else { + apply_entity_import(&mut tree, &child_id, child_payload); } } written += 1; diff --git a/server/src/reducer.rs b/server/src/reducer.rs index a36cd5c9287d61536f9f4a3f6b0df2342388857a..4e42d0369dab50bb2f8ca664aa69b628292f6c07 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -209,14 +209,24 @@ impl GlobalTree { } } - /// Directly attach `child` under `parent`, bypassing path-based nesting. - /// Used for imported listings (e.g. a subreddit's posts) so they show up - /// as children of the subreddit rather than a deep `…/comments/` path. - pub fn link_child(&mut self, parent: &ItemId, child: &ItemId) { + /// Import entity data for `id` and attach it as a direct child of `parent` + /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/` + /// parent rules pulling intermediate path segments into the subreddit). + pub fn apply_entity_under_parent( + &mut self, + parent: &ItemId, + id: &ItemId, + payload: Value, + view: Option, + ) { self.ensure_path(parent); - self.ensure_path(child); + self.ensure_node(id); + if let Some(node) = self.nodes.get_mut(id) { + node.entity_raw = Some(payload); + node.data = view; + } if let Some(p) = self.nodes.get_mut(parent) { - p.children.insert(child.clone()); + p.children.insert(id.clone()); } } } diff --git a/test/reddit_import.clj b/test/reddit_import.clj index 84cbdcf7965e50290313cbce2243a16b583d2097..45a2a19f20799d77e84d8aa64735ab5e7e45f97c 100644 --- a/test/reddit_import.clj +++ b/test/reddit_import.clj @@ -67,6 +67,36 @@ (do (Thread/sleep 200) (recur)) false))))) +(defn- run-reddit-fetch-assertions [app-base data-dir] + (let [browse-url (str app-base "/~/https://reddit.com/r/rust") + log-path (str data-dir "/events.jsonl") + before (:out (process/shell {:out :string :err :string} + "curl" "-sf" browse-url))] + (is (str/includes? before "Fetch from Reddit")) + (is (not (str/includes? before "The Rust Programming Language"))) + (let [sse (curl-fetch-ui-sse app-base "reddit.com/r/rust" "self")] + (is (zero? (:exit sse)) "POST /ui fetch_entity (self) SSE succeeds") + (is (str/includes? (:out sse) "Idiomorph.morph")) + (is (str/includes? (:out sse) "The Rust Programming Language")) + (is (wait-event-log log-path 2000) "event log written")) + (let [after (:out (process/shell {:out :string :err :string} + "curl" "-sf" browse-url)) + log (slurp (io/file log-path))] + (is (str/includes? after "The Rust Programming Language")) + (is (str/includes? log "\"type\":\"entity_imported\"")) + (is (str/includes? log "\"subscribers\":350000")) + (is (str/includes? log "\"display_name\":\"rust\""))) + (let [children-sse (curl-fetch-ui-sse app-base "reddit.com/r/rust" "children")] + (is (zero? (:exit children-sse)) "POST /ui fetch_entity (children) SSE succeeds") + (is (str/includes? (:out children-sse) "Idiomorph.morph")) + (is (str/includes? (:out children-sse) "Announcing Rust 1.99"))) + (let [after-children (:out (process/shell {:out :string :err :string} + "curl" "-sf" browse-url)) + log2 (slurp (io/file log-path))] + (is (str/includes? after-children "Announcing Rust 1.99")) + (is (str/includes? after-children "Unranked")) + (is (str/includes? log2 "announcing_rust_199"))))) + (deftest reddit-fetch-via-mock-api (testing "Fetch more queues import; event log stores full payload; page shows title" (let [root (repo-root) @@ -102,24 +132,7 @@ bin)] (try (is (wait-health app-base 20000) "app healthz") - (let [browse-url (str app-base "/~/https://reddit.com/r/rust") - before (:out (process/shell {:out :string :err :string} - "curl" "-sf" browse-url))] - (is (str/includes? before "Fetch from Reddit")) - (is (not (str/includes? before "The Rust Programming Language"))) - (let [log-path (str data-dir "/events.jsonl") - sse (curl-fetch-ui-sse app-base "reddit.com/r/rust")] - (is (zero? (:exit sse)) "POST /ui fetch_entity SSE succeeds") - (is (str/includes? (:out sse) "event: complete")) - (is (str/includes? (:out sse) "The Rust Programming Language")) - (is (wait-event-log log-path 2000) "event log written") - (let [after (:out (process/shell {:out :string :err :string} - "curl" "-sf" browse-url)) - log (slurp (io/file log-path))] - (is (str/includes? after "The Rust Programming Language")) - (is (str/includes? log "\"type\":\"entity_imported\"")) - (is (str/includes? log "\"subscribers\":350000")) - (is (str/includes? log "\"display_name\":\"rust\""))))) + (run-reddit-fetch-assertions app-base data-dir) (finally (process/destroy proc)))) (finally diff --git a/test/smoke.clj b/test/smoke.clj index 11887c48282088e140d823a88ba616f6325835b3..ce9f958c84b9a89ae55e215ab519f1df6435e24b 100644 --- a/test/smoke.clj +++ b/test/smoke.clj @@ -49,7 +49,7 @@ (is (wait-health base 15000) "server responds to /healthz") (let [home (:out (process/shell {:out :string :err :string} "curl" "-sf" (str base "/")))] - (is (str/includes? home "vote-panel")) + (is (str/includes? home "entity-section")) (is (str/includes? home "ranking-panel")) (is (str/includes? home "parser-panel")) (is (str/includes? home "__rpc__")))