Side A fixes a genuine, well-diagnosed algorithmic bug (bipartite Markov chain from wrong normalization divisor) with a principled, cited fix and solid regression tests across multiple topologies. Side B is a mixed bag: a URL-path refactor (dropping a '/' separator, seemingly cosmetic/risky) bundled with unrelated YouTube URL normalization and Url-as-HashMap-key documentation tests, lacking a clear unifying rationale and touching many call sites for a stylistic route change of uncertain necessity/value.
constitution · epochs · watch · epoch 3
c_48aeaf9b52c3 (tommy-mor) vs c_45dfa34511fe (tommy-mor)
download prompt · raw event · cmp_c038a25b7a1167
council reasoning
A is a precise core bugfix: it replaces the incorrect weight-sum d_max with the paper's degree-based d_max so Rank Centrality is aperiodic and star topologies converge to the right stationary distribution (π=1/2,1/4,1/4) instead of oscillating to uniform, backed by targeted Rust and end-to-end Clojure regression fixtures. B is a large but mostly structural URLs/routing refactor (concatenated room segments, helpers, YouTube normalization) whose lasting substance is secondary to the ranking correctness fix.
Side A fixes a core correctness bug in the ranking algorithm by switching Rank Centrality to the canonical degree-based d_max, eliminating oscillation in star topologies and adding focused regression tests (Rust and end-to-end fixture tests) that verify the corrected behavior. Side B is a broad URL/routing refactor that centralizes room path handling and adds URL normalization utilities, but it is largely structural and API reshaping rather than fixing a comparably fundamental algorithmic correctness issue.
sides
A — c_48aeaf9b52c3 (tommy-mor)
message
[595b3850] Fix star-topology ranking by using degree-based d_max (#146). A pure forward star at the default `>` ratio (2:1) produced uniform 1/3 scores, and the alphabetical-fallback sort placed the unambiguous winner last. Root cause: `compute_scores_from_edges` divided by the max sum of pairwise-normalized weights, so every node ended up with P_ii = 0 — a bipartite Markov chain whose power iteration oscillated and, after the configured even iteration count, returned to the uniform initial state. Switch the divisor to the unweighted max neighbor degree, matching the canonical Rank Centrality definition in Negahban–Oh–Shah 2012 §3.1 (arXiv:1209.1688, eq. defP and the d_max definition in §6). This gives every non-saturated node a positive self-loop, makes the chain aperiodic, and converges the star to π_zebra = 1/2, π_alpha = π_beta = 1/4. Add Rust regression test and a Clojure test that drives the sorterc binary against four .sorter fixtures (star, inverse star, chain, cycle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff preview
diff --git a/server/src/ranking.rs b/server/src/ranking.rs
index 3710c9f64437f5bef3b2121905b6f3bcb7611047..38e6d09b4370e5f8cbae09c0e5760b4e7f1ef7db 100644
--- a/server/src/ranking.rs
+++ b/server/src/ranking.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use crate::path_types::ItemId;
use crate::reducer::GroupState;
@@ -143,23 +143,35 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator<Item = ((usize, usiz
}
}
+ // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):
+ // P_ij = (1/d_max) * A_ij for i ≠ j compared
+ // P_ii = 1 - (1/d_max) * Σ_k A_ik
+ // where d_i is the *degree* (number of distinct neighbors compared) and
+ // d_max = max_i d_i. Using the unweighted degree — not the sum of
+ // pairwise-normalized weights — is what guarantees aperiodicity: it
+ // forces P_ii > 0 for every non-maximum-degree node, and for max-degree
+ // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous
+ // loss). Without this, regular comparison graphs (e.g. a pure star at
+ // ratio 2:1) produce a bipartite chain that oscillates instead of
+ // converging — see issue #146.
let mut out_edges: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
- let mut out_deg: Vec<f64> = vec![0.0; n];
+ let mut neighbors: Vec<HashSet<usize>> = vec![HashSet::new(); n];
for ((src, dst), w) in &normalized {
out_edges[*src].push((*dst, *w));
- out_deg[*src] += w;
+ neighbors[*src].insert(*dst);
+ neighbors[*dst].insert(*src);
}
- let mut max_out = 0.0f64;
- for &d in &out_deg {
- if d > max_out {
- max_out = d;
- }
- }
- if max_out <= 1e-12 {
+ let weight_sum: Vec<f64> = out_edges
+ .iter()
+ .map(|es| es.iter().map(|(_, w)| *w).sum())
+ .collect();
+ let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);
+ if d_max == 0 {
return vec![1.0 / n as f64; n];
}
+ let d_max_f = d_max as f64;
let mut scores = vec![1.0 / n as f64; n];
let mut next = vec![0.0f64; n];
@@ -167,14 +179,14 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator<Item = ((usize, usiz
for _ in 0..max_iters {
next.fill(0.0);
for i in 0..n {
- let stay_prob = (max_out - out_deg[i]) / max_out;
+ let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;
next[i] += scores[i] * stay_prob;
if out_edges[i].is_empty() {
continue;
}
for &(dst, w) in &out_edges[i] {
- next[dst] += scores[i] * (w / max_out);
+ next[dst] += scores[i] * (w / d_max_f);
}
}
@@ -270,6 +282,38 @@ mod tests {
}
}
+ /// Regression for issue #146: pure forward star at default `>` ratio (2:1).
+ /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the
+ /// chain was bipartite; power iteration oscillated and returned the
+ /// uniform initial distribution after an even number of steps. Using the
+ /// paper's degree-based d_max gives every node a positive self-loop and
+ /// the chain converges to the correct stationary distribution.
+ #[test]
+ fn star_topology_winner_at_top_via_subset() {
+ let mut g = mk_group();
+ g.apply_vote(vote(1, "zebra", "alpha", 2, 1));
+ g.apply_vote(vote(2, "zebra", "beta", 2, 1));
+
+ let mut items: Vec<(usize, String)> = g
+ .idx_to_item
+ .iter()
+ .enumerate()
+ .map(|(i, it)| (i, it.as_str().to_string()))
+ .collect();
+ items.sort_by(|a, b| a.1.cmp(&b.1));
+ let idxs: Vec<usize> = items.iter().map(|(i, _)| *i).collect();
+
+ let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);
+ for r in &ranked {
+ eprintln!("{}: {}", r.item.as_str(), r.score);
+ }
+ assert_eq!(
+ ranked[0].item.as_str(),
+ "https://slug.social/zebra",
+ "zebra won both votes and should rank #1"
+ );
+ }
+
#[test]
fn group_ranking_cache_dirty_flow() {
let mut g = mk_group();
diff --git a/test/fixtures/ranking/chain.sorter b/test/fixtures/ranking/chain.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..912a96bdc08f631be27f3c9afc7e05004a2457c0
--- /dev/null
+++ b/test/fixtures/ranking/chain.sorter
@@ -0,0 +1,10 @@
+#t3
+
+~/t3/a { head of chain }
+~/t3/b { middle }
+~/t3/c { tail }
+
+{ a > b }
+~/t3/a > ~/t3/b
+{ b > c }
+~/t3/b > ~/t3/c
diff --git a/test/fixtures/ranking/cycle.sorter b/test/fixtures/ranking/cycle.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..771731ae176e5d77e00ab89767ff2d7465bb7c6b
--- /dev/null
+++ b/test/fixtures/ranking/cycle.sorter
@@ -0,0 +1,12 @@
+#t4
+
+~/t4/a { node a }
+~/t4/b { node b }
+~/t4/c { node c }
+
+{ a > b }
+~/t4/a > ~/t4/b
+{ b > c }
+~/t4/b > ~/t4/c
+{ c > a }
+~/t4/c > ~/t4/a
diff --git a/test/fixtures/ranking/star.sorter b/test/fixtures/ranking/star.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..135c9f9097d57b73c7ab18fac737ed3598d76fbe
--- /dev/null
+++ b/test/fixtures/ranking/star.sorter
@@ -0,0 +1,11 @@
+#repro
+
+~/repro/zebra { winner — beats both others }
+~/repro/alpha { loser — alphabetically first }
+~/repro/beta { loser — alphabetically middle }
+
+{ zebra beats alpha }
+~/repro/zebra > ~/repro/alpha
+
+{ zebra beats beta }
+~/repro/zebra > ~/repro/beta
diff --git a/test/fixtures/ranking/star_inverse.sorter b/test/fixtures/ranking/star_inverse.sorter
new file mode 100644
index 0000000000000000000000000000000000000000..dab842fa924e5124865ee216b9565f6c7471c812
--- /dev/null
+++ b/test/fixtures/ranking/star_inverse.sorter
@@ -0,0 +1,11 @@
+#t2
+
+~/t2/win { source of incoming edges (loses both) }
+~/t2/loss-a { winner }
+~/t2/loss-b { winner }
+
+{ loss-a beats win }
+~/t2/loss-a > ~/t2/win
+
+{ loss-b beats win }
+~/t2/loss-b > ~/t2/win
diff --git a/test/ranking.clj b/test/ranking.clj
new file mode 100644
index 0000000000000000000000000000000000000000..e1358783a84a264ee633bd79151ba29750b717cf
--- /dev/null
+++ b/test/ranking.clj
@@ -0,0 +1,74 @@
+(ns test.ranking
+ "Drives sorterc on .sorter fixtures and asserts ranking properties.
+
+ Regression coverage for issue #146 — pure forward star at default ratio
+ (2:1 for `>`) used to produce tied uniform scores because the random walk
+ on the normalized edge weights was bipartite. Fixed by switching to the
+ degree-based d_max from Negahban–Oh–Shah rank centrality (§3.1)."
+ (:require [clojure.test :refer [deftest is testing]]
+ [babashka.process :as p]
+ [cheshire.core :as json]
+ [clojure.java.io :as io]))
+
+(def sorterc-bin
+ "Path to the locally-built sorterc binary. Builds on demand if missing."
+ (let [dbg "target/debug/sorterc"
+ release "target/release/sorterc"]
+ (cond
+ (.exists (io/file release)) release
+ (.exists (io/file dbg)) dbg
+ :else
+ (do (println "building sorterc…")
+ (let [r (p/shell {:out :string :err :string :continue true}
+ "cargo build -p sorterc")]
+ (when-not (zero? (:exit r))
+ (throw (ex-info "cargo build -p sorterc failed"
+ {:stderr (:err r)}))))
+ dbg))))
+
+(defn compile-sorter [fixture-path]
+ (let [{:keys [out exit]} (p/shell {:out :string :err :string :continue true}
+ sorterc-bin "compile" fixture-path)]
+ (when-not (zero? exit)
+ (throw (ex-info "sorterc exit nonzero" {:fixture fixture-path :out out})))
+ (json/parse-string out true)))
+
+(defn first-component-ranking [result]
+ (-> result :rankings first :components first :ranking))
+
+(defn item-leaf [item]
+ (last (clojure.string/split item #"/")))
+
+(deftest chain-ranks-head-first
+ (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/chain.sorter"))
+ names (mapv (comp item-leaf :item) ranking)]
+ (is (= ["a" "b" "c"] names)
+ "chain a>b>c should rank a, b, c in order")
+ (is (apply > (map :score ranking))
+ "scores should attenuate strictly down the chain")))
+
+(deftest inverse-star-puts-winners-on-top
+ (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/star_inverse.sorter"))
+ names (mapv (comp item-leaf :item) ranking)]
+ (is (= "win" (last names))
+ "the item that lost to both others should be ranked last")))
+
+(deftest cycle-produces-uniform-scores
+ (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/cycle.sorter"))
+ scores (map :score ranking)]
+ (is (every? #(< (Math/abs (- % 1/3)) 1e-3) scores)
+ "a perfectly symmetric 3-cycle should give every node ~1/3")))
+
+(deftest star-topology-winner-at-top
+ ;; Issue #146 regression: source-only star at default `>` ratio (2:1).
+ ;; Pre-fix produced uniform 1/3 scores; alphabetical fallback put the
+ ;; unambiguous winner at the bottom. Post-fix the chain is aperiodic and
+ ;; converges to π_zebra = 1/2, π_alpha = π_beta = 1/4.
+ (let [ranking (first-component-ranking (compile-sorter "test/fixtures/ranking/star.sorter"))
+ names (mapv (comp item-leaf :item) ranking)
+ by-name (into {} (map (juxt (comp item-leaf :item) :score) ranking))]
+ (is (= "zebra" (first names))
+ "zebra won both votes and should rank #1")
+ (is (< (Math/abs (- (by-name "zebra") 0.5)) 1e-3))
+ (is (< (Math/abs (- (by-name "alpha") 0.25)) 1e-3))
+ (is (< (Math/abs (- (by-name "beta") 0.25)) 1e-3))))
B — c_45dfa34511fe (tommy-mor)
message
[5ca518f6] url refactor
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index 67a09a3b54f778fa7e857fdd589c3ed9c92e1322..ad7e4fe6d4ba2f2b033916194c1ef1ed873f1d46 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1757,6 +1757,7 @@ name = "slug-types"
version = "0.1.0"
dependencies = [
"serde",
+ "url",
]
[[package]]
@@ -2272,6 +2273,7 @@ dependencies = [
"idna",
"percent-encoding",
"serde",
+ "serde_derive",
]
[[package]]
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 606f7d6f97a4428efb90d1e0d544934c861fc4c5..cd3e0f0afd972d9ad9e7e4b92c5fa4c22bb8f620 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -270,10 +270,10 @@ fn post_redirect_location(room: &str, thread_tag: &str) -> String {
format!("/t/{tag}")
} else {
let room = room.trim();
- let Some((a, b)) = room.split_once('/') else {
+ let Some(seg) = slug_types::room_route_segment(room) else {
return "/".to_string();
};
- format!("/r/{a}/{b}/t/{tag}")
+ format!("/r/{seg}/t/{tag}")
}
}
diff --git a/server/src/api/write_actor.rs b/server/src/api/write_actor.rs
index cb78d2f3f95b1bc163c1fb064d1d5f657e18000f..f9c3b8bd3fbf8fcb9c035e1a1572fef0b08fa8a9 100644
--- a/server/src/api/write_actor.rs
+++ b/server/src/api/write_actor.rs
@@ -19,13 +19,15 @@ use crate::{
use super::auth::{issue_token_for_user, verify_token};
use super::helpers::{now_ms, resolve_item};
use super::validate::{normalize_room_and_thread, validate_ingest_document};
-use slug_types::RpcResult;
+use slug_types::{room_route_segment, RpcResult, ROOM_SHORT_ID_LEN};
fn gen_short_id() -> String {
use rand::Rng;
const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::thread_rng();
- (0..7).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()
+ (0..ROOM_SHORT_ID_LEN)
+ .map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char)
+ .collect()
}
fn parse_capability(s: &str) -> Result<crate::events::ThreadCapability, String> {
@@ -55,8 +57,8 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str
let feed_id = if room_key == "public" { "thread-feed" } else { "room-thread-feed" };
let thread_url = if room_key == "public" {
format!("/t/{thread_id}")
- } else if let Some((short, slug)) = room_key.split_once('/') {
- format!("/r/{short}/{slug}/t/{thread_id}")
+ } else if let Some(seg) = room_route_segment(room_key) {
+ format!("/r/{seg}/t/{thread_id}")
} else {
format!("/t/{thread_id}")
};
@@ -78,8 +80,8 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str
let js = builder.build();
let mut path_prefixes = vec![if room_key == "public" {
"/".to_string()
- } else if let Some((short, slug)) = room_key.split_once('/') {
- format!("/r/{short}/{slug}")
+ } else if let Some(seg) = room_route_segment(room_key) {
+ format!("/r/{seg}")
} else {
"/".to_string()
}];
diff --git a/server/src/html/forum/nav.rs b/server/src/html/forum/nav.rs
index 0ee33d91160fc5542817b5e3e9ab4fee1d0e600f..48fe11e46731670874ff8b6b05baa6f09ae0b7e4 100644
--- a/server/src/html/forum/nav.rs
+++ b/server/src/html/forum/nav.rs
@@ -1,7 +1,8 @@
use crate::canonical_path::canonicalize_item;
use crate::reducer::ScopeId;
+use slug_types::room_route_segment;
-/// URL helpers for public `/t/…` and private room threads `/r/{short}/{slug}/t/…`.
+/// URL helpers for public `/t/…` and private room threads `/r/{short}{slug}/t/…`.
#[derive(Clone)]
pub struct ThreadNav {
pub room_wire: String,
@@ -22,18 +23,15 @@ impl ThreadNav {
}
}
- /// `room_id` wire form `shortid/slug`.
+ /// `room_id` wire form `shortid/slug` (HTTP uses [`slug_types::room_route_segment`]).
pub(crate) fn from_room_id(room_id: &str) -> Option<Self> {
- let (short, slug) = room_id.split_once('/')?;
- if short.is_empty() || slug.is_empty() {
- return None;
- }
+ let room_seg = room_route_segment(room_id)?;
Some(Self {
room_wire: room_id.to_string(),
scope: ScopeId::Room(room_id.to_string()),
- room_path: format!("/r/{short}/{slug}"),
- thread_path_prefix: format!("/r/{short}/{slug}/t"),
- garden_path_prefix: format!("/r/{short}/{slug}/~"),
+ room_path: format!("/r/{room_seg}"),
+ thread_path_prefix: format!("/r/{room_seg}/t"),
+ garden_path_prefix: format!("/r/{room_seg}/~"),
})
}
diff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs
index c316f8f836df9d4ef9c05ebd9e54f699540e6d72..473747b3da3d4d7a54b5e0c63165d2533df643e1 100644
--- a/server/src/html/forum/post_single.rs
+++ b/server/src/html/forum/post_single.rs
@@ -93,12 +93,14 @@ pub async fn thread_post_view(
pub async fn room_thread_post_view(
State(state): State<AppState>,
- Path((room_short, room_slug, tag, index_str)): Path<(String, String, String, String)>,
+ Path((room_key, tag, index_str)): Path<(String, String, String)>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let reduced = state.reduced.read().await;
let user = optional_principal(&headers, &jar, &reduced);
if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs
index be5df1745ef580891a167c23c3dd6c06f804f295..1ec421f84335cbfe7f9db775b73a8ed24b197174 100644
--- a/server/src/html/forum/views.rs
+++ b/server/src/html/forum/views.rs
@@ -183,16 +183,18 @@ pub async fn thread_view(
thread_view_inner(state, tag, q, ThreadNav::public(), headers, jar, uri).await
}
-/// Room thread — `/r/:short/:slug/t/:tag`
+/// Room thread — `/r/:room_key/t/:tag` (`room_key` = `{short}{slug}`).
pub async fn room_thread_view(
State(state): State<AppState>,
- Path((room_short, room_slug, tag)): Path<(String, String, String)>,
+ Path((room_key, tag)): Path<(String, String)>,
Query(q): Query<ThreadViewQuery>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let reduced = state.reduced.read().await;
let user = optional_principal(&headers, &jar, &reduced);
if !user_can_view_room(&reduced, &room_id, user.as_deref()) {
@@ -226,15 +228,17 @@ pub(super) fn room_not_found_page(jar: &CookieJar, uri: &Uri) -> impl IntoRespon
(StatusCode::NOT_FOUND, Html(page.into_string()))
}
-/// Private room index — `/r/:short/:slug`
+/// Private room index — `/r/:room_key`
pub async fn room_page(
State(state): State<AppState>,
- Path((room_short, room_slug)): Path<(String, String)>,
+ Path(room_key): Path<String>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "room not found").into_response();
+ };
let now = now_ms();
let reduced = state.reduced.read().await;
if !reduced.rooms.contains(&room_id) {
@@ -266,7 +270,10 @@ pub async fn room_page(
let audit_cli = format!("npx slugsocial private {room_id} audit");
drop(reduced);
- let slug_display = room_slug.as_str();
+ let slug_display = room_id
+ .split_once('/')
+ .map(|(_, slug)| slug)
+ .unwrap_or(room_id.as_str());
let page = layout(
&format!("room {slug_display} — slug.social"),
"view-thread",
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 423f23fd8c9ad7b7f454d6ea7a9a7607a4c9c5b9..e615dd356bcf634232d85610c0a26235ead125fd 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -309,12 +309,14 @@ pub async fn external_ontology_path(
pub async fn room_garden_index(
State(state): State<AppState>,
- Path((room_short, room_slug)): Path<(String, String)>,
+ Path(room_key): Path<String>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
@@ -341,12 +343,14 @@ pub async fn room_garden_index(
pub async fn room_external_garden_index(
State(state): State<AppState>,
- Path((room_short, room_slug)): Path<(String, String)>,
+ Path(room_key): Path<String>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
@@ -416,12 +420,14 @@ pub async fn room_external_garden_index(
pub async fn room_external_ontology_path(
State(state): State<AppState>,
- Path((room_short, room_slug, path)): Path<(String, String, String)>,
+ Path((room_key, path)): Path<(String, String)>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
@@ -442,12 +448,14 @@ pub async fn room_external_ontology_path(
pub async fn room_ontology_path(
State(state): State<AppState>,
- Path((room_short, room_slug, path)): Path<(String, String, String)>,
+ Path((room_key, path)): Path<(String, String)>,
headers: HeaderMap,
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
- let room_id = format!("{room_short}/{room_slug}");
+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {
+ return (StatusCode::NOT_FOUND, "bad room path").into_response();
+ };
let Some(nav) = ThreadNav::from_room_id(&room_id) else {
return (StatusCode::NOT_FOUND, "bad room path").into_response();
};
diff --git a/server/src/html/search.rs b/server/src/html/search.rs
index 43e6ebf36cf0fe72c96f0f9d850bea51ac094c43..f01732f7edb32c68fcc10c39545c8f56476adb27 100644
--- a/server/src/html/search.rs
+++ b/server/src/html/search.rs
@@ -351,8 +351,8 @@ fn render_search_results(results: &SearchResults, query: &str) -> Markup {
ul class="search-posts" {
@for r in &results.posts {
@let (post_href, post_label) = if let Some((room, tag)) = r.thread.split_once("/#") {
- if let Some((short, slug)) = room.split_once('/') {
- (format!("/r/{short}/{slug}/t/{tag}"), format!("{room}/#{tag}"))
+ if let Some(seg) = slug_types::room_route_segment(room) {
+
… preview truncated; 35,812 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.