Side B fixes a real off-by-one bug (thread_post_index was incorrectly 1-indexed with a fallback masking missing entries) with corrected docs and new regression tests covering the exact indices, giving lasting correctness value. Side A is a useful but purely mechanical config simplification (collapsing two hand-listed kaocha suites into a wildcard pattern) with no bugfix and lower risk/benefit.
constitution · epochs · watch · epoch 3
c_28dfd8015c8a (tommy-mor) vs c_6a02ffb06a41 (tommy-mor)
download prompt · raw event · cmp_4898db90cfbf14
council reasoning
B fixes real product correctness: thread_post_index is aligned to 0-based /t/tag/N routes, silent unwrap_or(0) fallbacks become hard expects, the garden link is always emitted, and integration assertions lock the contract. A only collapses hand-enumerated kaocha ns-patterns into one ^test\..+ suite—a useful test-ops cleanup, but not a lasting behavioral fix.
Side B fixes a semantic inconsistency by making `thread_post_index` consistently 0-based for rank history, replacing silent `unwrap_or(0)` fallbacks with `expect(...)` to enforce an invariant, updating documentation, rendering, and adding integration tests that verify the behavior. Side A improves maintainability by replacing manually enumerated Kaocha test namespaces with a single auto-discovered `^test\..+` suite, but it is primarily a test configuration simplification rather than a functional correctness change.
sides
A — c_28dfd8015c8a (tommy-mor)
message
[0bebe819] Unify kaocha test discovery to one auto-discovered suite. Replace the two hand-enumerated suites (:http-integration, :browser) with a single :all suite that picks up every test.* namespace under test/. New test files now run automatically without touching tests.edn — needed for the test.ranking namespace added alongside the #146 fix, and for any future tests. Both ./TEST.sh and CI (.github/workflows/ci.yml) invoke `clojure -M:kaocha`, so both pick up the new suite without changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff preview
diff --git a/tests.edn b/tests.edn
index 5e2b0433a66c51806d08ef38d691259cfccd1fe0..e51ffcc81eeae6f41c85d1bfc5ba32f592cc5818 100644
--- a/tests.edn
+++ b/tests.edn
@@ -1,30 +1,14 @@
#kaocha/v1
-{:tests
- [{:id :http-integration
- :test-paths ["test"]
- :source-paths ["."]
- :ns-patterns ["^test\\.integration$"
- "^test\\.auth$"
- "^test\\.grants$"
- "^test\\.invites$"
- "^test\\.room-list$"]
- :kaocha.filter/skip-meta [:skip]
- :parallel? false}
- {:id :browser
- :test-paths ["test"]
- :source-paths ["."]
- :ns-patterns ["^test\\.browser-sse$"
- "^test\\.browser-ui-morph$"
- "^test\\.browser-post-redact$"
- "^test\\.browser-room-delete$"
- "^test\\.browser-public-garden$"
- "^test\\.browser-redact-thread-index$"
- "^test\\.browser-garden-pin$"
- "^test\\.browser-vote-compare$"
- "^test\\.browser-github-resolver$"]
- :kaocha.filter/skip-meta [:skip]
- :parallel? false}]
- :plugins [:kaocha.plugin/junit-xml]
- :kaocha.plugin.junit-xml/target-file "target/kaocha-junit.xml"
- :kaocha.plugin.junit-xml/add-location-metadata? true
- :reporter kaocha.report.progress/report}
+ {:tests
+ [{:id :all
+ :test-paths ["test"]
+ :source-paths ["."]
+ ;; Pick up every test.* namespace under test/. New files don't need to be
+ ;; enumerated here — drop them in test/ with `(ns test.foo …)` and they run.
+ :ns-patterns ["^test\\..+"]
+ :kaocha.filter/skip-meta [:skip]
+ :parallel? false}]
+ :plugins [:kaocha.plugin/junit-xml]
+ :kaocha.plugin.junit-xml/target-file "target/kaocha-junit.xml"
+ :kaocha.plugin.junit-xml/add-location-metadata? true
+ :reporter kaocha.report.progress/report}
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.