Side B fixes a real off-by-one/indexing bug (0-based vs 1-based thread_post_index mismatch used in URLs), removes a dead conditional in the template, and adds regression tests plus doc clarifications across three files, giving concrete lasting correctness value. Side A merely removes a redundant early-return guard that was already dead code, a smaller and lower-impact cleanup with a single adjusted test.
constitution · epochs · watch · epoch 3
c_cf0a514b261a (tommy-mor) vs c_6a02ffb06a41 (tommy-mor)
download prompt · raw event · cmp_6492c95bf8a8d6
council reasoning
B fixes real indexing inconsistency by switching rank-history thread_post_index from 1-based-with-silent-0-default to shared 0-based paths (/t/tag/N), replacing unwrap_or(0) with invariant enforcement and updating API/UI/tests/docs accordingly. A only deletes a dead zero-ratio guard already enforced upstream and retunes one unit test, which is sound cleanup but lower lasting impact.
Side B corrects and standardizes the thread post index semantics across RPC, HTML, types, and integration tests: it switches rank history to the intended 0-based `/t/tag/N` indexing, removes the silent `unwrap_or(0)` fallback in favor of asserting the expected invariant with `expect(...)`, and updates rendering and documentation accordingly. Side A is a worthwhile cleanup that removes a redundant zero-ratio guard and updates tests to reflect that zero-weight edges are skipped elsewhere, but it is primarily dead-code removal relying on existing validation.
sides
A — c_cf0a514b261a (tommy-mor)
message
[047b82bd] Remove redundant zero-ratio guard from reducer. Zeros are already rejected at the DSL parser and browser handler; the guard in apply_vote was dead code. The negative clamping stays since add_edge_weight already skips weight-0 edges correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 0e36979abe0f051493038ff7e652efc7f7a0ac80..efbf7f67ff24c7a2989102fe62879b2794a27c5f 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -112,11 +112,6 @@ impl GroupState {
if vote.ratio_right < 0 {
vote.ratio_right = 0;
}
- if vote.ratio_left == 0 || vote.ratio_right == 0 {
- // Zero on either side produces no valid edge; drop before registering items or pair.
- return;
- }
-
let a_idx = self.ensure_item(&vote.a);
let b_idx = self.ensure_item(&vote.b);
diff --git a/server/tests/basic.rs b/server/tests/basic.rs
index 08159f4a7f0850fd165817a4a1af4f31ced2ad76..1748769ccf7196b2d81cf556d5368c7e723f6a4e 100644
--- a/server/tests/basic.rs
+++ b/server/tests/basic.rs
@@ -533,7 +533,7 @@ fn dsl_parse_rejects_zero_zero_vote_ratio() {
#[test]
fn reducer_negative_ratio_clamped_to_zero() {
let _state = ReducerState::default();
- // GroupState::apply_vote clamps negatives to 0; when either side is 0 the vote is dropped.
+ // apply_vote clamps negatives to 0; add_edge_weight skips zero-weight edges.
let mut group = GroupState::new();
group.apply_vote(slugsocial_server::reducer::VoteData {
ts: 1,
@@ -546,10 +546,9 @@ fn reducer_negative_ratio_clamped_to_zero() {
delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
thread_tag: "t".to_string(),
});
- // Nothing registered: zero-clamped vote is dropped before ensure_item.
- assert!(group.idx_to_item.is_empty());
+ // Items and pair are registered; edges are absent because weight 0 is skipped.
+ assert_eq!(group.idx_to_item.len(), 2);
assert!(group.edges.is_empty());
- assert!(group.voted_pairs.is_empty());
}
B — c_6a02ffb06a41 (tommy-mor)
message
[3bc88847] removed optional
diff preview
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index ba93ca87182d49f57dffc8220f604ffa150f9a6c..5049a26b096bd8435d6eb9e75ccb751b6f489061 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1337,8 +1337,7 @@ pub async fn handle_rpc_batch(
.ingests_by_scope_thread
.get(&(scope.clone(), e.thread.clone()))
.and_then(|q| q.iter().rev().position(|id| id == &e.post_id))
- .map(|i| i + 1)
- .unwrap_or(0);
+ .expect("rank history post_id must be in ingests_by_scope_thread for (scope, thread)");
RankHistoryRow {
ts: e.ts,
scope_rank: e.scope_rank,
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 319feb15a6b68d2b5df98b4289fedbc9bdd048d3..23245d6fdfc9019b199ab8417150faf5f3297067 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -450,6 +450,7 @@ struct RankHistoryEntryView {
scope_total: usize,
scope_rank_delta: i32,
thread: String,
+ /// 0-based index as [`crate::html::forum::ingest::thread_post_index_in_scope`] / `/t/tag/N`.
thread_post_index: usize,
caused_by: Vec<crate::reducer::VoteData>,
}
@@ -573,11 +574,11 @@ fn build_rank_history(
})
.unwrap_or_default();
- let thread_post_index = reduced.ingests_by_scope_thread
+ let thread_post_index = reduced
+ .ingests_by_scope_thread
.get(&(scope.clone(), e.thread.clone()))
.and_then(|q| q.iter().rev().position(|id| id == &e.post_id))
- .map(|i| i + 1)
- .unwrap_or(0);
+ .expect("rank history post_id must be in ingests_by_scope_thread for (scope, thread)");
RankHistoryEntryView {
ts: e.ts,
@@ -704,11 +705,9 @@ async fn render_scope_view(
span class="muted" { (ago) (label) }
" · "
a href=(thread_href(&e.thread)) { "#" (e.thread) }
- @if e.thread_post_index > 0 {
- " "
- a href=(format!("{}/{}", thread_href(&e.thread), e.thread_post_index)) {
- span class="muted" { "post #" (e.thread_post_index) }
- }
+ " "
+ a href=(format!("{}/{}", thread_href(&e.thread), e.thread_post_index)) {
+ span class="muted" { "post #" (e.thread_post_index) }
}
}
@if e.caused_by.is_empty() {
diff --git a/server/tests/integration.rs b/server/tests/integration.rs
index d4c5bfe9c6f1c71dc61878bd8c5e729b1b7c69ef..9766adb43ad315a64f5df17b79f66718ce149509 100644
--- a/server/tests/integration.rs
+++ b/server/tests/integration.rs
@@ -1322,6 +1322,11 @@ async fn test_rank_history() {
assert_eq!(entry["scope_rank_delta"], 0, "delta is 0 on first appearance");
let caused_by = entry["caused_by"].as_array().unwrap();
assert_eq!(caused_by.len(), 2, "both votes in the ingest touched rust");
+ assert_eq!(
+ entry["thread_post_index"],
+ 0,
+ "rank history links use same 0-based index as /t/hist-test/0"
+ );
ingest(
"00000000-0000-0000-0000-000000000002:rig:test/model",
@@ -1349,6 +1354,16 @@ async fn test_rank_history() {
assert_eq!(caused_by2.len(), 1);
assert!(caused_by2[0]["a"].as_str().unwrap().ends_with("python") ||
caused_by2[0]["b"].as_str().unwrap().ends_with("python"));
+ assert_eq!(
+ hist2[0]["thread_post_index"],
+ 0,
+ "first hist-test post is chronological index 0"
+ );
+ assert_eq!(
+ hist2[1]["thread_post_index"],
+ 1,
+ "second ingest is chronological index 1"
+ );
let hist_rust2 = rpc_batch(
&client,
diff --git a/types/src/lib.rs b/types/src/lib.rs
index edbb923e4d7d41ad82dfc254c3bd697562383527..49abba8ea9f786ef68e3157d2a5d309e15e09ba0 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -253,7 +253,7 @@ pub struct FeedPost {
/// Primary thread tag (without #), if the ingest declared one.
#[serde(skip_serializing_if = "Option::is_none")]
pub thread: Option<String>,
- /// 1-indexed chronological position of this post within the thread.
+ /// 1-based display ordinal for this post within the thread (feed only; URLs use 0-based paths).
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_post_index: Option<usize>,
/// Full raw body of the ingest document.
@@ -628,7 +628,7 @@ pub struct RankHistoryRow {
pub score: f64,
/// Thread tag of the ingest that triggered this rank change.
pub thread: String,
- /// 1-indexed chronological position of this post within the thread.
+ /// 0-indexed chronological position of this post within the thread (same as `/t/tag/N` routes).
pub thread_post_index: usize,
/// Votes from this ingest that directly touched this item. Empty when change was transitive.
pub caused_by: Vec<VoteRow>,
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.