constitution · epochs · watch · epoch 3

comparison

c_ef71be9831cc (tommy-mor) vs c_9bced108c8aa (tommy-mor)

download prompt · raw event · cmp_2276cfba527b9c

council reasoning

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

A is a clean internal refactor (GroupState -> ScopeVotes) that removes cached edge state in favor of deriving it on demand, improving simplicity but is mostly mechanical renaming/restructuring of existing logic. B introduces a new, well-tested semantic URL graph (DFA traversal, builder with validation, generic fallback, extensive equivalence-class and regression tests) that adds real, durable functionality for canonicalizing and breadcrumbing URLs across reddit/youtube/generic sites, representing more net new capability with strong test coverage.

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

Commit A redesigns the core vote model (GroupState → ScopeVotes), dropping maintained edges/idx maps/rollback paths so ranking and pair logic derive weights from uuid_votes on demand across reducer, ranking, storage, and UI. Commit B adds a solid URL DFA/canonicalization stack with tests, but it is new peripheral infrastructure rather than a lasting simplification of the project's central ranking state.

openai/gpt-chat-latest · winner A · 4:1 · permalink

Side A implements a substantial architectural refactor: it replaces cached `GroupState` with a simpler `ScopeVotes` model, derives ranking edges and connected components on demand, updates persistence and ranking algorithms, and adapts callers and tests throughout the project. Side B adds a sizable URL canonicalization/graph subsystem with extensive tests, but it is largely an isolated new feature, whereas Side A simplifies core state management and removes redundant cached data in a way that affects the project's central ranking pipeline.

sides

A — c_ef71be9831cc (tommy-mor)

message

[af73743d] Replace GroupState with ScopeVotes and derive edges at ranking time.

Store only uuid_votes and recent_votes per scope; rank centrality and pair logic rebuild edge weights on demand instead of maintaining cached state.

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

diff preview

diff --git a/server/src/events.rs b/server/src/events.rs
index 8a166d49b4f26835fbc2b58cb1f4bdbf002763b8..015208311f6c5c23a0e8aab068d002a69c89c4e1 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -43,7 +43,7 @@ pub enum ViewEvent {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 #[serde(tag = "type", rename_all = "snake_case")]
 pub enum Event {
-    /// Pairwise comparison vote (replayed into the parent node's [`crate::reducer::GroupState`] on boot).
+    /// Pairwise comparison vote (replayed into the parent node's [`crate::reducer::ScopeVotes`] on boot).
     /// `scope` is the parent [`crate::path_types::ItemId`] string; empty string is the tree root.
     VoteRecorded {
         ts: i64,
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 4eff2e19ed4d303ff8e80c1eabd8a15b4990e643..1e2e7a06856d8a62378741aaf5ed94a4ffed337e 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -13,7 +13,7 @@ use crate::{
     form_template::template_json_compact,
     path_types::ItemId,
     ranking::{
-        connected_components_from_voted_pairs, ranked_items_subset, RankedItem, MAX_ITERS, TOL,
+        ranked_items_subset, scope_components, RankedItem, MAX_ITERS, TOL,
     },
     reducer::{GlobalTree, NodeState},
     state::AppState,
@@ -397,10 +397,9 @@ pub fn ranking_panel_with_highlights(
     tree: &GlobalTree,
     highlighted: &HashSet<ItemId>,
 ) -> Markup {
-    let group = &node.local_ranking;
-    let n = group.idx_to_item.len();
-    let (comps, _isolates) =
-        connected_components_from_voted_pairs(n, group.voted_pairs.iter().copied());
+    let scope = &node.votes;
+    let (comps, _isolates, _) =
+        scope_components(scope);
 
     // Each connected component of voted items is its own ranking; isolated and
     // never-voted children fall into the "unranked" bucket below.
@@ -410,7 +409,7 @@ pub fn ranking_panel_with_highlights(
         if comp.len() < 2 {
             continue;
         }
-        let ranked = ranked_items_subset(group, comp, MAX_ITERS, TOL);
+        let ranked = ranked_items_subset(scope, comp, MAX_ITERS, TOL);
         for r in &ranked {
             ranked_ids.insert(r.item.clone());
         }
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
index bf82aef3ad9c5f4e9e877c47dab27beb29a80b8f..3aa00c417c89a9cab3417c650b50ed7c73f08e20 100644
--- a/server/src/html/vote.rs
+++ b/server/src/html/vote.rs
@@ -14,7 +14,7 @@ use crate::{
     html::{ranking_panel_with_highlights, scope_theme_style, JsBuilder},
     pair::{children_of, resolve_pair, suggest_next_pair_in_pool},
     path_types::ItemId,
-    reducer::{GlobalTree, GroupState, NodeState, VoteData},
+    reducer::{GlobalTree, NodeState, ScopeVotes, VoteData},
     state::{parse_item_param, AppState},
     ui_action::UI_RPC_FIELD,
 };
@@ -68,8 +68,8 @@ fn ratios_for_page(v: &VoteData, page_left: &ItemId, page_right: &ItemId) -> (i3
     }
 }
 
-fn edge_votes(group: &GroupState, left: &ItemId, right: &ItemId) -> Vec<VoteData> {
-    group
+fn edge_votes(scope: &ScopeVotes, left: &ItemId, right: &ItemId) -> Vec<VoteData> {
+    scope
         .recent_votes
         .iter()
         .filter(|v| {
@@ -113,11 +113,11 @@ fn slider_value_from_ratios(r_left: i32, r_right: i32) -> i32 {
 
 fn vote_edge_history(
     tree: &GlobalTree,
-    group: &GroupState,
+    scope: &ScopeVotes,
     left: &ItemId,
     right: &ItemId,
 ) -> Markup {
-    let mut votes = edge_votes(group, left, right);
+    let mut votes = edge_votes(scope, left, right);
     votes.sort_by(|a, b| b.ts.cmp(&a.ts));
     let legend_left = child_title(tree, left);
     let legend_right = child_title(tree, right);
@@ -228,9 +228,9 @@ pub(crate) fn vote_recorded_morph(
 ) -> JsBuilder {
     let pool = children_of(tree, parent);
     let empty = NodeState::default();
-    let group = tree.get(parent).unwrap_or(&empty).local_ranking.clone();
-    let edge_history = vote_edge_history(tree, &group, left, right);
-    let next_pair = suggest_next(&group, left, right, &pool);
+    let scope = tree.get(parent).unwrap_or(&empty).votes.clone();
+    let edge_history = vote_edge_history(tree, &scope, left, right);
+    let next_pair = suggest_next(&scope, left, right, &pool);
     let actions = vote_compare_actions(parent, next_pair.as_ref());
     let sidebar = vote_ranking_sidebar(tree, parent, left, right);
     JsBuilder::new()
@@ -252,12 +252,12 @@ fn vote_compare_item_card(tree: &GlobalTree, item: &ItemId, side_class: &str) ->
 }
 
 fn suggest_next(
-    group: &GroupState,
+    scope: &ScopeVotes,
     left: &ItemId,
     right: &ItemId,
     pool: &[ItemId],
 ) -> Option<(ItemId, ItemId)> {
-    suggest_next_pair_in_pool(group, pool, Some((left, right)))
+    suggest_next_pair_in_pool(scope, pool, Some((left, right)))
 }
 
 pub async fn vote_page(
@@ -284,9 +284,9 @@ pub async fn vote_page(
         };
 
     let pool = children_of(&tree, &parent);
-    let group = &parent_node.local_ranking;
-    let next_pair = suggest_next(group, &left, &right, &pool);
-    let edge_history = vote_edge_history(&tree, group, &left, &right);
+    let scope = &parent_node.votes;
+    let next_pair = suggest_next(&scope, &left, &right, &pool);
+    let edge_history = vote_edge_history(&tree, &scope, &left, &right);
 
     let rpc_json = template_json_compact(&serde_json::json!({
         "action": "record_vote",
@@ -388,8 +388,8 @@ mod polarity_tests {
         let mut tree = GlobalTree::new();
         tree.apply_vote(&parent, vote, TEST_ACTOR_UUID);
 
-        let group = &tree.get(&parent).unwrap().local_ranking;
-        let ranked = ranked_items(group);
+        let scope = &tree.get(&parent).unwrap().votes;
+        let ranked = ranked_items(scope);
         assert_eq!(
             ranked[0].item, left,
             "left item should rank first when ratio favours the left"
diff --git a/server/src/pair.rs b/server/src/pair.rs
index 42a1b1eb2adf16730d34d0fe23c13d5a75d7ba27..9873295c51526726089875bbfd2f97d7faa91872 100644
--- a/server/src/pair.rs
+++ b/server/src/pair.rs
@@ -11,8 +11,8 @@ use std::collections::{HashMap, HashSet};
 
 use crate::{
     path_types::ItemId,
-    ranking::{connected_components_from_voted_pairs, ranked_items},
-    reducer::{GlobalTree, GroupState},
+    ranking::{pair_is_voted, ranked_items, scope_components},
+    reducer::{GlobalTree, ScopeVotes},
 };
 
 fn pairs_match(a: &ItemId, b: &ItemId, x: &ItemId, y: &ItemId) -> bool {
@@ -23,26 +23,15 @@ fn pair_excluded(a: &ItemId, b: &ItemId, exclude: Option<(&ItemId, &ItemId)>) ->
     exclude.is_some_and(|(x, y)| pairs_match(a, b, x, y))
 }
 
-fn pair_is_voted(group: &GroupState, a: &ItemId, b: &ItemId) -> bool {
-    let Some(&ai) = group.item_to_idx.get(a) else {
-        return false;
-    };
-    let Some(&bi) = group.item_to_idx.get(b) else {
-        return false;
-    };
-    let (i, j) = if ai < bi { (ai, bi) } else { (bi, ai) };
-    group.voted_pairs.contains(&(i, j))
-}
 
 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());
+fn component_layout(scope: &ScopeVotes, pool: &[ItemId]) -> ComponentLayout {
+    let (comps, isolates, idx_to_item) = scope_components(scope);
+    let n = idx_to_item.len();
 
     let mut established = HashSet::new();
     let mut ids: HashMap<ItemId, usize> = HashMap::new();
@@ -52,14 +41,14 @@ fn component_layout(group: &GroupState, pool: &[ItemId]) -> ComponentLayout {
         }
         for &idx in comp {
             if idx < n {
-                ids.insert(group.idx_to_item[idx].clone(), comp_idx);
+                ids.insert(idx_to_item[idx].clone(), comp_idx);
             }
         }
     }
     let mut next = comps.len();
     for &idx in &isolates {
         if idx < n {
-            ids.insert(group.idx_to_item[idx].clone(), next);
+            ids.insert(idx_to_item[idx].clone(), next);
             next += 1;
         }
     }
@@ -121,7 +110,7 @@ fn established_groups_in_pool<'a>(
     groups
 }
 
-fn ranked_pool_order(group: &GroupState, pool: &[ItemId]) -> Vec<ItemId> {
+fn ranked_pool_order(group: &ScopeVotes, pool: &[ItemId]) -> Vec<ItemId> {
     let pool_set: HashSet<_> = pool.iter().collect();
     ranked_items(group)
         .into_iter()
@@ -132,7 +121,7 @@ fn ranked_pool_order(group: &GroupState, pool: &[ItemId]) -> Vec<ItemId> {
 
 /// Walk 1↔2, 2↔3, …; optional `require_unvoted` skips voted edges.
 fn zip_adjacent_pair(
-    group: &GroupState,
+    group: &ScopeVotes,
     order: &[ItemId],
     exclude: Option<(&ItemId, &ItemId)>,
     require_unvoted: bool,
@@ -153,7 +142,7 @@ fn zip_adjacent_pair(
 
 /// Grow the voted graph toward one component (no rank centrality).
 fn suggest_grow_pair(
-    group: &GroupState,
+    group: &ScopeVotes,
     pool: &[ItemId],
     layout: &ComponentLayout,
     exclude: Option<(&ItemId, &ItemId)>,
@@ -216,7 +205,7 @@ fn suggest_grow_pair(
 
 /// Pick the next pair to vote on within `pool`.
 pub fn suggest_next_pair_in_pool(
-    group: &GroupState,
+    group: &ScopeVotes,
     pool: &[ItemId],
     exclude: Option<(&ItemId, &ItemId)>,
 ) -> Option<(ItemId, ItemId)> {
@@ -315,7 +304,7 @@ pub fn resolve_pair(
         (None, None) => {
             let group = tree
                 .get(parent)
-                .map(|n| &n.local_ranking)
+                .map(|n| &n.votes)
                 .cloned()
                 .unwrap_or_default();
             suggest_next_pair_in_pool(&group, &children, None).ok_or(PairError::NoPair)
@@ -398,7 +387,7 @@ mod tests {
                 "https://reddit.com/r/rust/b",
             ],
         );
-        let group = tree.get(&parent).unwrap().local_ranking.clone();
+        let group = tree.get(&parent).unwrap().votes.clone();
         let pool = children_of(&tree, &parent);
         assert!(!pair_is_voted(&group, &pool[0], &pool[1]));
         assert!(suggest_next_pair_in_pool(&group, &pool, None).is_some());
@@ -417,7 +406,7 @@ mod tests {
         );
         let vote = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
         apply(&mut tree, &parent, vote);
-        let group = tree.get(&parent).unwrap().local_ranking.clone();
+        let group = tree.get(&parent).unwrap().votes.clone();
         let pool = children_of(&tree, &parent);
         let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
         let voted_ab = (l.as_str() == "https://reddit.com/r/rust/a" && r.as_str() == "https://reddit.com/r/rust/b")
@@ -441,7 +430,7 @@ mod tests {
         let cd = test_vote(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1);
         apply(&mut tree, &parent, ab);
         apply(&mut tree, &parent, cd);
-        let group = tree.get(&parent).unwrap().local_ranking.clone();
+        let group = tree.get(&parent).unwrap().votes.clone();
         let pool = children_of(&tree, &parent);
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
         let chosen = pair_set(&pair);
@@ -467,7 +456,7 @@ mod tests {
         );
         let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
         apply(&mut tree, &parent, ab);
-        let group = tree.get(&parent).unwrap().local_ranking.clone();
+        let group = tree.get(&parent).unwrap().votes.clone();
         let pool = children_of(&tree, &parent);
         let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
         let chosen = pair_set(&pair);
@@ -496,7 +485,7 @@ mod tests {
         );
         let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
         apply(&mut tree, &parent, ab);
-        let group = tree.get(&parent).unwrap().local_ranking.clone();
+       

… preview truncated; 38,568 characters omitted

download full diff A

B — c_9bced108c8aa (tommy-mor)

message

[15e1037a] url stuff

diff preview

diff --git a/server/src/url_rules/graph.rs b/server/src/url_rules/graph.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f7ac0f9a551a1727cb2f9294778c283b9885b147
--- /dev/null
+++ b/server/src/url_rules/graph.rs
@@ -0,0 +1,831 @@
+//! Semantic URL graph: DFA traversal on host + path, query in context, generic fallback.
+
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+use url::Url;
+
+use super::graph_builder::GraphBuilder;
+use super::parse::{normalize_match_host, strip_tracking_query, UrlParts};
+
+#[derive(Debug, Clone, Default)]
+pub struct Context {
+    pub vars: HashMap<String, String>,
+    pub query: HashMap<String, String>,
+}
+
+pub type CanonicalFn = fn(&Context) -> Option<String>;
+
+#[derive(Clone, Copy)]
+pub enum EdgePattern {
+    Literal(&'static str),
+    Variable(&'static str),
+    /// Absorb any trailing segment without leaving this node (e.g. post title slug).
+    AbsorbAny,
+    /// Absorb segment when `cond(seg)` (e.g. subreddit listing suffix).
+    AbsorbIf(fn(&str) -> bool),
+}
+
+pub struct Edge {
+    pub pattern: EdgePattern,
+    pub target: &'static str,
+}
+
+pub struct Node {
+    pub edges: Vec<Edge>,
+    pub canonical: CanonicalFn,
+    pub parent: Option<&'static str>,
+}
+
+impl Node {
+    pub(crate) fn empty() -> Self {
+        Self {
+            edges: Vec::new(),
+            canonical: |_| None,
+            parent: None,
+        }
+    }
+}
+
+pub struct Graph {
+    pub nodes: HashMap<&'static str, Node>,
+}
+
+static GRAPH: OnceLock<Graph> = OnceLock::new();
+
+pub fn graph() -> &'static Graph {
+    GRAPH.get_or_init(build_graph)
+}
+
+impl Graph {
+    pub fn resolve_canonical(&self, parts: &UrlParts) -> Option<String> {
+        let mut query = parts.query.clone();
+        strip_tracking_query(&mut query);
+        let mut ctx = Context {
+            vars: HashMap::new(),
+            query,
+        };
+
+        if let Some(node_id) = self.traverse(parts, &mut ctx) {
+            if let Some(canon) = (self.nodes.get(node_id)?.canonical)(&ctx) {
+                return Some(canon);
+            }
+        }
+        Some(generic_canonical(parts))
+    }
+
+    pub fn breadcrumbs(&self, parts: &UrlParts) -> Vec<String> {
+        let mut query = parts.query.clone();
+        strip_tracking_query(&mut query);
+        let mut ctx = Context {
+            vars: HashMap::new(),
+            query,
+        };
+
+        if let Some(mut node_id) = self.traverse(parts, &mut ctx) {
+            let mut paths = Vec::new();
+            loop {
+                let node = match self.nodes.get(node_id) {
+                    Some(n) => n,
+                    None => break,
+                };
+                if let Some(url) = (node.canonical)(&ctx) {
+                    if paths.last() != Some(&url) {
+                        paths.push(url);
+                    }
+                }
+                match node.parent {
+                    Some(p) => node_id = p,
+                    None => break,
+                }
+            }
+            paths.reverse();
+            if !paths.is_empty() {
+                return paths;
+            }
+        }
+        generic_breadcrumbs(parts)
+    }
+
+    fn traverse(&self, parts: &UrlParts, ctx: &mut Context) -> Option<&'static str> {
+        let host = parts.match_host();
+        let mut node_id = match host.as_str() {
+            "reddit.com" => "reddit_root",
+            "youtube.com" => "youtube_root",
+            "youtu.be" => "youtu_be_entry",
+            _ => return None,
+        };
+
+        let segs: Vec<&str> = parts.path_segments.iter().map(String::as_str).collect();
+        let mut i = 0;
+        while i < segs.len() {
+            let seg = segs[i];
+            match self.follow_edge(node_id, seg, ctx) {
+                Ok(next) => {
+                    node_id = next;
+                    i += 1;
+                }
+                Err(()) => {
+                    if self.try_absorb(node_id, seg) {
+                        i += 1;
+                        continue;
+                    }
+                    return None;
+                }
+            }
+        }
+        Some(node_id)
+    }
+
+    fn follow_edge(
+        &self,
+        node_id: &'static str,
+        seg: &str,
+        ctx: &mut Context,
+    ) -> Result<&'static str, ()> {
+        let node = self.nodes.get(node_id).ok_or(())?;
+        for edge in &node.edges {
+            match edge.pattern {
+                EdgePattern::Literal(lit) if lit == seg => return Ok(edge.target),
+                EdgePattern::Variable(name) => {
+                    ctx.vars.insert(name.to_string(), seg.to_string());
+                    return Ok(edge.target);
+                }
+                EdgePattern::AbsorbAny
+                | EdgePattern::AbsorbIf(_)
+                | EdgePattern::Literal(_)
+                | EdgePattern::Variable(_) => {}
+            }
+        }
+        Err(())
+    }
+
+    fn try_absorb(&self, node_id: &'static str, seg: &str) -> bool {
+        let node = match self.nodes.get(node_id) {
+            Some(n) => n,
+            None => return false,
+        };
+        for edge in &node.edges {
+            match edge.pattern {
+                EdgePattern::AbsorbAny => return true,
+                EdgePattern::AbsorbIf(cond) if cond(seg) => return true,
+                EdgePattern::AbsorbIf(_) | EdgePattern::Literal(_) | EdgePattern::Variable(_) => {}
+            }
+        }
+        false
+    }
+
+    /// Test hook: terminal graph node and captured context after traversal.
+    #[cfg(test)]
+    pub fn traverse_terminal(&self, parts: &UrlParts) -> Option<(&'static str, Context)> {
+        let mut query = parts.query.clone();
+        strip_tracking_query(&mut query);
+        let mut ctx = Context {
+            vars: HashMap::new(),
+            query,
+        };
+        let node = self.traverse(parts, &mut ctx)?;
+        Some((node, ctx))
+    }
+}
+
+fn is_reddit_listing_suffix(seg: &str) -> bool {
+    matches!(seg, "hot" | "top" | "new" | "rising" | "controversial")
+}
+
+/// Percent-encode a path or query fragment so `&`, `?`, etc. cannot break URL structure.
+fn enc(s: &str) -> String {
+    urlencoding::encode(s).into_owned()
+}
+
+// --- Canonical formatters ---
+
+fn canon_reddit_root(_: &Context) -> Option<String> {
+    Some("https://reddit.com".to_string())
+}
+
+fn canon_reddit_r_hub(_: &Context) -> Option<String> {
+    Some("https://reddit.com/r".to_string())
+}
+
+fn canon_reddit_subreddit(ctx: &Context) -> Option<String> {
+    let sub = ctx.vars.get("subreddit")?;
+    Some(format!(
+        "https://reddit.com/r/{}",
+        enc(&sub.to_ascii_lowercase())
+    ))
+}
+
+fn canon_reddit_post(ctx: &Context) -> Option<String> {
+    let sub = ctx.vars.get("subreddit")?.to_ascii_lowercase();
+    let id = ctx.vars.get("post_id")?;
+    Some(format!(
+        "https://reddit.com/r/{}/comments/{}",
+        enc(&sub),
+        enc(id)
+    ))
+}
+
+fn canon_youtube_root(_: &Context) -> Option<String> {
+    Some("https://youtube.com".to_string())
+}
+
+fn canon_youtube_watch(ctx: &Context) -> Option<String> {
+    let v = ctx
+        .query
+        .get("v")
+        .or_else(|| ctx.vars.get("video_id"))?;
+    Some(format!("https://youtube.com/watch?v={}", enc(v)))
+}
+
+fn canon_youtu_be(ctx: &Context) -> Option<String> {
+    let v = ctx.vars.get("vid_id")?;
+    Some(format!("https://youtube.com/watch?v={}", enc(v)))
+}
+
+pub fn build_graph() -> Graph {
+    GraphBuilder::new()
+        .node("reddit_root")
+        .canonical(canon_reddit_root)
+        .edge(EdgePattern::Literal("r"), "reddit_r_hub")
+        .node("reddit_r_hub")
+        .parent("reddit_root")
+        .canonical(canon_reddit_r_hub)
+        .edge(EdgePattern::Variable("subreddit"), "reddit_subreddit")
+        .node("reddit_subreddit")
+        .parent("reddit_r_hub")
+        .canonical(canon_reddit_subreddit)
+        .edge(
+            EdgePattern::AbsorbIf(is_reddit_listing_suffix),
+            "reddit_subreddit",
+        )
+        .edge(EdgePattern::Literal("comments"), "reddit_comments_gate")
+        .node("reddit_comments_gate")
+        .parent("reddit_subreddit")
+        .canonical(canon_reddit_subreddit)
+        .edge(EdgePattern::Variable("post_id"), "reddit_post")
+        .node("reddit_post")
+        .parent("reddit_subreddit")
+        .canonical(canon_reddit_post)
+        .edge(EdgePattern::AbsorbAny, "reddit_post")
+        .node("youtube_root")
+        .canonical(canon_youtube_root)
+        .edge(EdgePattern::Literal("watch"), "youtube_watch")
+        .edge(EdgePattern::Literal("shorts"), "youtube_shorts_gate")
+        .node("youtube_watch")
+        .parent("youtube_root")
+        .canonical(canon_youtube_watch)
+        .node("youtube_shorts_gate")
+        .parent("youtube_root")
+        .canonical(canon_youtube_root)
+        .edge(EdgePattern::Variable("video_id"), "youtube_watch")
+        .node("youtu_be_entry")
+        .canonical(canon_youtube_root)
+        .edge(EdgePattern::Variable("vid_id"), "youtu_be_video")
+        .node("youtu_be_video")
+        .parent("youtube_root")
+        .canonical(canon_youtu_be)
+        .build()
+}
+
+// --- Generic internet fallback ---
+
+pub fn generic_canonical(parts: &UrlParts) -> String {
+    let host = normalize_match_host(&parts.host);
+    let path_segments: Vec<String> = parts.path_segments.clone();
+    let mut query = parts.query.clone();
+    strip_tracking_query(&mut query);
+
+    let mut url = if path_segments.is_empty() {
+        Url::parse(&format!("https://{host}"))
+            .unwrap_or_else(|_| Url::parse("https://invalid").unwrap())
+    } else {
+        let path = format!("/{}", path_segments.join("/"));
+        Url::parse(&format!("https://{host}{path}"))
+            .unwrap_or_else(|_| Url::parse("https://invalid").unwrap())
+    };
+
+    if !query.is_empty() {
+        let mut pairs: Vec<_> = query.iter().collect();
+        pairs.sort_by(|a, b| a.0.cmp(b.0));
+        url.query_pairs_mut().clear();
+        for (k, v) in pairs {
+            url.query_pairs_mut().append_pair(k, v);
+        }
+    }
+
+    let mut s = url.to_string();
+    if path_segments.is_empty() {
+        s = s.trim_end_matches('/').to_string();
+    }
+    s
+}
+
+pub fn generic_breadcrumbs(parts: &UrlParts) -> Vec<String> {
+    let host = normalize_match_host(&parts.host);
+    let n = parts.path_segments.len();
+    let mut out = Vec::new();
+
+    let base = generic_canonical(&UrlParts {
+        scheme: "https".to_string(),
+        host: host.clone(),
+        path_segments: vec![],
+        query: HashMap::new(),
+    });
+    out.push(base);
+
+    for i in 0..n {
+        let segs: Vec<String> = parts.path_segments[..=i].to_vec();
+        let url = generic_canonical(&UrlParts {
+            scheme: "https".to_string(),
+            host: host.clone(),
+            path_segments: segs,
+            query: HashMap::new(),
+        });
+        if out.last() != Some(&url) {
+            out.push(url);
+        }
+    }
+    out
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::url_rules::parse::test_parts;
+
+    fn g() -> &'static Graph {
+        graph()
+    }
+
+    fn canon(parts: &UrlParts) -> String {
+        g().resolve_canonical(parts).unwrap()
+    }
+
+    fn crumbs(parts: &UrlParts) -> Vec<String> {
+        g().breadcrumbs(parts)
+    }
+
+    fn terminal(parts: &UrlParts) -> Option<&'static str> {
+        g().traverse_terminal(parts).map(|(n, _)| n)
+    }
+
+    fn vars(parts: &UrlParts) -> HashMap<String, String> {
+        g().traverse_terminal(parts)
+            .map(|(_, c)| c.vars)
+            .unwrap_or_default()
+    }
+
+    #[test]
+    fn youtu_be_malicious_segment_encoded_not_injected() {
+        let p = test_parts("youtu.be", &["abc&t=1"], &[]);
+        assert_eq!(canon(&p

… preview truncated; 29,502 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.