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.
constitution · epochs · watch · epoch 3
c_ef71be9831cc (tommy-mor) vs c_9bced108c8aa (tommy-mor)
download prompt · raw event · cmp_2276cfba527b9c
council reasoning
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.
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 omittedB — 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 omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.