B fixes a concrete UX bug (the HUD 'pin' link actually just navigated instead of unpinning, now properly issues a set_garden_pin clear POST) and adds a genuinely useful feature (per-edge vote counts on the compare icon), backed by both a Rust unit test and an updated browser test. A's refactor swaps a write-time-capped VecDeque for an uncapped append-only List with cap-on-read slicing, which removes bounded-growth guarantees on disk (recent_votes can now grow unboundedly, requiring a full scan/slice at read time) — a debatable tradeoff whose benefit isn't clearly established beyond the rename, despite adding a decent test for the new cap-on-read behavior.
constitution · epochs · watch · epoch 3
c_a896b2dc05d5 (tommy-mor) vs c_3ff71f7eaeda (tommy-mor)
download prompt · raw event · cmp_c1f10558c8187b
council reasoning
A redesigns durable recent-votes from Deque to append-only List/Vec, drops write-path trim_recent_votes and projection batch bookkeeping, bumps schema, and caps only on read with a solid regression test—core lasting storage simplification. B is real product polish (HUD unpin fix, edge vote counts, CSS/tests) but stays UI-layer and does not change foundational durability semantics.
Side A makes a substantive persistence-model redesign by replacing the durable recent-vote deque with an append-only list, removing post-commit trimming, updating schema versioning, changing in-memory storage, and adding a test that verifies query-time capping of recent votes. Side B mainly improves UI behavior (HUD unpin action and displaying pairwise vote counts) with associated styling and browser/tests, which is useful but has less architectural impact than the storage and data-flow simplification in Side A.
sides
A — c_a896b2dc05d5 (tommy-mor)
message
[1531154d] dequeue -> vec
diff preview
diff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs
index 9c8990a8af927f35d3344c8d0872a516aba56b86..ad404bacb8bcdd5ae0e682cff97f974fd44528ea 100644
--- a/server/src/projection_apply.rs
+++ b/server/src/projection_apply.rs
@@ -6,8 +6,6 @@
//! batch as the (non-idempotent) edge merges guarantees exactly-once application
//! across replay.
-use std::collections::BTreeSet;
-
use crate::{
event_log::EventLogError,
events::{Event, EventRecord},
@@ -44,7 +42,6 @@ pub fn apply_records(
let db = projection_store.db();
let mut batch = db.batch();
- let mut vote_parents: BTreeSet<ItemId> = BTreeSet::new();
let mut last_seq = 0u64;
for record in records {
@@ -70,7 +67,6 @@ pub fn apply_records(
*ts,
)
.map_err(|e| EventLogError::Apply(e.to_string()))?;
- vote_parents.insert(parent);
}
Event::NodeEnsured { id } => {
let parsed = parse_event_id(id)?;
@@ -85,11 +81,5 @@ pub fn apply_records(
.commit_with(durable::Durability::DisableWal)
.map_err(|e| EventLogError::Apply(e.to_string()))?;
- for parent in vote_parents {
- projection_store
- .trim_recent_votes(&parent)
- .map_err(|e| EventLogError::Apply(e.to_string()))?;
- }
-
Ok(())
}
diff --git a/server/src/projection_store.rs b/server/src/projection_store.rs
index 8576d671f351004426207894ac35594ddb0f70cf..9a8953d010029d3639dc3987687554bab8b7663e 100644
--- a/server/src/projection_store.rs
+++ b/server/src/projection_store.rs
@@ -18,7 +18,7 @@ use crate::{
const PROJECTION_CURSOR_KEY: &str = "cursor";
const PROJECTION_SCHEMA_KEY: &str = "schema_version";
-const PROJECTION_SCHEMA_VERSION: u64 = 3;
+const PROJECTION_SCHEMA_VERSION: u64 = 4;
#[derive(Debug, thiserror::Error)]
pub enum ProjectionStoreError {
@@ -142,16 +142,6 @@ impl ProjectionStore {
Ok(tree)
}
- /// Cap a node's recent-vote window after applying votes (best-effort, blind).
- pub(crate) fn trim_recent_votes(&self, parent: &ItemId) -> Result<(), ProjectionStoreError> {
- node(parent).recent_votes().truncate_back(
- &self.db,
- crate::storage_schema::RECENT_VOTES_CAP,
- Durability::DisableWal,
- )?;
- Ok(())
- }
-
/// Cache Reddit display content outside the event log (must be evicted per policy).
pub fn put_ephemeral_content(
&self,
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 0c75c85150bb9e5f578bbadf58b3e43f8a80be4b..759918b8c0eb8f8bf1ed0911d8877adaa55c8ea6 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -1,4 +1,4 @@
-use std::collections::{HashMap, HashSet, VecDeque};
+use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
@@ -52,7 +52,7 @@ pub struct GroupState {
pub idx_to_item: Vec<ItemId>,
pub edges: HashMap<(usize, usize), f64>,
pub voted_pairs: HashSet<(usize, usize)>,
- pub recent_votes: VecDeque<VoteData>,
+ pub recent_votes: Vec<VoteData>,
}
impl GroupState {
@@ -62,7 +62,7 @@ impl GroupState {
idx_to_item: Vec::new(),
edges: HashMap::new(),
voted_pairs: HashSet::new(),
- recent_votes: VecDeque::with_capacity(200),
+ recent_votes: Vec::new(),
}
}
@@ -111,10 +111,7 @@ impl GroupState {
self.add_edge_weight(b_idx, a_idx, w_a);
self.add_edge_weight(a_idx, b_idx, w_b);
- self.recent_votes.push_front(vote);
- while self.recent_votes.len() > 200 {
- self.recent_votes.pop_back();
- }
+ self.recent_votes.push(vote);
}
}
diff --git a/server/src/storage_dto.rs b/server/src/storage_dto.rs
index 9dfb13c53efe4389277625a6ab3bfc18f566a453..3fd6db5cb909ac4896bd8a3ecace796de5f08781 100644
--- a/server/src/storage_dto.rs
+++ b/server/src/storage_dto.rs
@@ -39,7 +39,7 @@ pub struct StoredEntityDataV1 {
pub link_url: Option<String>,
}
-/// One vote stored in a node's `recent_votes` deque.
+/// One vote stored in a node's `recent_votes` list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredVoteV1 {
pub version: u32,
diff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs
index bd26e665e084b95b10fdfff091c31e8dc84d07b8..5d2bb1d56927fb61c7c6d2d8602bd6882327f862 100644
--- a/server/src/storage_schema.rs
+++ b/server/src/storage_schema.rs
@@ -2,13 +2,13 @@
//! durable collections instead of one blob per node.
//!
//! A vote updates a handful of keys: a few edge-weight merges, a voted-pair flag,
-//! a recent-vote deque push, and child-link set entries. The in-memory
+//! a recent-vote list append, and child-link set entries. The in-memory
//! [`crate::reducer::GroupState`] is reconstructed from these keys on read for
//! rank-centrality.
use std::collections::{BTreeSet, HashMap, HashSet};
-use durable::{Batch, Db, Deque, Durable, Leaf, Map, Sum};
+use durable::{Batch, Db, Durable, Leaf, List, Map, Sum};
use crate::{
path_types::ItemId,
@@ -38,8 +38,8 @@ pub struct NodeSchema {
pub edges: Map<EdgeKey, Sum<f64>>,
/// Voted pairs `(min, max) -> true`.
pub voted_pairs: Map<PairKey, Leaf<bool>>,
- /// Recent votes, newest at the front (capped on write).
- pub recent_votes: Deque<Leaf<StoredVoteV1>>,
+ /// Recent votes, append-only oldest-first (cap applied on read).
+ pub recent_votes: List<Leaf<StoredVoteV1>>,
/// When ephemeral Reddit display content was last fetched (ms); absent after eviction.
pub fetched_at: Leaf<i64>,
}
@@ -55,7 +55,7 @@ pub struct Store {
pub view_meta: Map<String, Leaf<u64>>,
}
-/// Cap on the per-node recent-vote window (matches the in-memory reducer).
+/// Max recent votes returned when loading a node (query-time cap only).
pub const RECENT_VOTES_CAP: u64 = 200;
fn id_key(id: &ItemId) -> String {
@@ -148,11 +148,14 @@ fn build_group_state(
}
}
- // Deque is front=newest; in-memory VecDeque is also front=newest.
- let mut recent_votes = std::collections::VecDeque::new();
- for stored in np.recent_votes().iter(db)? {
- recent_votes.push_back(decode_vote(stored).map_err(durable::Error::Deserialize)?);
- }
+ // List is index order (oldest first); keep the newest RECENT_VOTES_CAP entries.
+ let stored = np.recent_votes().iter(db)?;
+ let cap = RECENT_VOTES_CAP as usize;
+ let start = stored.len().saturating_sub(cap);
+ let recent_votes = stored[start..]
+ .iter()
+ .map(|s| decode_vote(s.clone()).map_err(durable::Error::Deserialize))
+ .collect::<Result<Vec<_>, _>>()?;
Ok(GroupState {
item_to_idx,
@@ -248,7 +251,7 @@ pub fn vote_writes(
};
batch.write(pnode.voted_pairs().key(&(lo, hi)).set(&true));
- // Recent votes (newest at front).
+ // Recent votes (append-only; cap on read).
let stored = encode_vote(&VoteData {
ts,
a: a_id,
@@ -260,7 +263,7 @@ pub fn vote_writes(
delegate: None,
thread_tag: "default".to_string(),
});
- batch.push_front(&pnode.recent_votes(), &stored)?;
+ batch.push(&pnode.recent_votes(), &stored)?;
Ok(())
}
@@ -314,6 +317,35 @@ mod tests {
assert!(load_node_state(&db, &parent).unwrap().is_none());
}
+ #[test]
+ fn load_caps_recent_votes_at_query_time() {
+ let dir = tempfile::tempdir().unwrap();
+ let db = Db::open(dir.path()).unwrap();
+ let parent = ItemId::root();
+
+ let mut batch = db.batch();
+ for i in 0..RECENT_VOTES_CAP + 10 {
+ vote_writes(&mut batch, &parent, "alpha", "beta", 1, 0, i as i64).unwrap();
+ }
+ batch.commit().unwrap();
+
+ assert_eq!(
+ node(&parent).recent_votes().len(&db).unwrap(),
+ RECENT_VOTES_CAP + 10
+ );
+
+ let node_state = load_node_state(&db, &parent).unwrap().unwrap();
+ assert_eq!(node_state.local_ranking.recent_votes.len(), RECENT_VOTES_CAP as usize);
+ assert_eq!(
+ node_state.local_ranking.recent_votes.first().map(|v| v.ts),
+ Some(10)
+ );
+ assert_eq!(
+ node_state.local_ranking.recent_votes.last().map(|v| v.ts),
+ Some(RECENT_VOTES_CAP as i64 + 9)
+ );
+ }
+
#[test]
fn missing_node_is_none() {
let dir = tempfile::tempdir().unwrap();
B — c_3ff71f7eaeda (tommy-mor)
message
[30a67104] fixes
diff preview
diff --git a/agents.md b/agents.md
index a6a283716e09fcaba1fd690f4e877e0bbecda2c0..d9a924d2f77c444d9b112bbf37a480b963ace4f0 100644
--- a/agents.md
+++ b/agents.md
@@ -39,7 +39,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card) and **`#vote-edge-history-region`** (recomputed edge list). Uses **`RpcResult::PostOk`**’s **`post_id`** / **`post_index`** for the card. **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.
-- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** when **`layout`** passes garden metadata on **`body`**.
+- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`**. HUD: **`#slug-pin-hud`** when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
**Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate.
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index 8a46b1e92d38f2c390f88d48750d95d11388cd73..121d9498e8cb93d4d001dc1bbce23d74fbb958f5 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -16,7 +16,6 @@ use crate::{
canonical_path::{canonicalize_item, canonicalize_tag},
form_template::template_json_compact,
html::{
- forum::ingest_entry_markup,
ui_action::UI_RPC_FIELD,
user_can_post_room,
JsBuilder,
@@ -111,6 +110,23 @@ fn votes_for_edge(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec<crate::
out
}
+/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).
+fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {
+ let (lo, hi) = canonical_edge_items(a, b);
+ let lo_s = lo.as_str();
+ let hi_s = hi.as_str();
+ content
+ .item_votes
+ .get(&lo)
+ .into_iter()
+ .flat_map(|q| q.iter())
+ .filter(|v| {
+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)
+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)
+ })
+ .count()
+}
+
fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec<String> {
let set: HashSet<String> = content
.item_threads
@@ -288,6 +304,7 @@ fn child_row_pin_or_vote(
nav: &ThreadNav,
row_item: &ItemId,
pinned_room_and_item: Option<&(String, ItemId)>,
+ scope_content: &ContentState,
next_path: &str,
) -> maud::Markup {
let pin_matches_scope = pinned_room_and_item
@@ -315,7 +332,16 @@ fn child_row_pin_or_vote(
@if pi == row_item {
span class="ont-garden-pinned-here" title="Pinned" aria-label="Pinned" { "📌" }
} @else {
- a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title="Vote vs pinned" aria-label="Vote" { "⚖" }
+ @let nv = edge_vote_count_for_pair(scope_content, pi, row_item);
+ @let tip = format!(
+ "Compare and vote — {nv} pairwise vote{} in this scope for pinned vs this row",
+ if nv == 1 { "" } else { "s" },
+ );
+ @let aria = format!("Vote; {} pairwise {}", nv, if nv == 1 { "vote" } else { "votes" });
+ a class="ont-garden-vote-ico" href=(vote_compare_href(nav, pi, row_item, None)) title=(tip) aria-label=(aria) {
+ span class="ont-garden-vote-glyph" aria-hidden="true" { "⚖" }
+ span class="ont-garden-vote-count" { (format!("{}", nv)) }
+ }
}
} @else {
form method="POST" action="/ui" data-navigate="full" class="ont-pin-form ont-garden-pin-form" {
@@ -978,10 +1004,9 @@ async fn render_scope_view(
) -> axum::response::Response {
let scope = nav.scope();
let pin_ref = pinned_item_from_jar(&jar);
- let model = {
- let reduced = state.reduced.read().await;
- build_item_page_view_model(&reduced, &scope, browse.item())
- };
+ let reduced = state.reduced.read().await;
+ let model = build_item_page_view_model(&reduced, &scope, browse.item());
+ let scope_content = content_for_garden_view(&reduced, &scope);
let thread_href = |tag: &str| nav.thread_url(tag);
let external_empty_body = browse.is_external() && model.body.is_none();
let cli_path_arg = item_display_path(&model.item);
@@ -1108,7 +1133,7 @@ async fn render_scope_view(
@let item_url = item_href(r.item.as_str(), &nav);
@let score_str = format!("{:.3}", r.score);
li data-garden-item=(r.item.as_str()) {
- (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), &next_for_pin))
+ (child_row_pin_or_vote(&nav, &r.item, pin_ref.as_ref(), scope_content, &next_for_pin))
a class="item-link" href=(item_url) { code { (item_display_path(r.item.as_str())) } }
span class="ont-rank-score" { (score_str) }
}
@@ -1124,7 +1149,7 @@ async fn render_scope_view(
ul class="ont-group-list" {
@for name in &model.child_rankings.unranked_items {
li data-garden-item=(name.as_str()) {
- (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), &next_for_pin))
+ (child_row_pin_or_vote(&nav, name, pin_ref.as_ref(), scope_content, &next_for_pin))
@let href = item_href(name.as_str(), &nav);
a class="item-link" href=(href) { code { (item_display_path(name.as_str())) } }
}
@@ -1363,6 +1388,33 @@ mod tests {
}));
}
+ #[test]
+ fn edge_vote_count_for_pair_matches_votes_for_edge_len() {
+ use super::{
+ content_for_garden_view, edge_vote_count_for_pair, votes_for_edge,
+ };
+ use crate::path_types::ItemId;
+ let mut reduced = ReducerState::default();
+ apply_ingest(
+ &mut reduced,
+ 1,
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n\
+ ~/topic {root}\n\
+ ~/topic/a {alpha}\n\
+ ~/topic/b {beta}\n\
+ ~/topic/a 3:2 ~/topic/b {first vote}\n\
+ ~/topic/b 2:3 ~/topic/a {second vote}\n",
+ );
+ let content = content_for_garden_view(&reduced, &ScopeId::Public);
+ let a = ItemId::parse("~/topic/a").unwrap().normalized_storage();
+ let b = ItemId::parse("~/topic/b").unwrap().normalized_storage();
+ assert_eq!(
+ edge_vote_count_for_pair(content, &a, &b),
+ votes_for_edge(content, &a, &b).len()
+ );
+ assert_eq!(votes_for_edge(content, &a, &b).len(), 2);
+ }
+
#[test]
fn item_page_model_includes_body_and_unranked_without_votes() {
let mut reduced = ReducerState::default();
diff --git a/server/static/slug_ui.js b/server/static/slug_ui.js
index c0de1cddbba78227dfb80bfd41e7b855b3a42bc3..86f935f8dd998f3d6df016f33b1e47f9780782ea 100644
--- a/server/static/slug_ui.js
+++ b/server/static/slug_ui.js
@@ -170,15 +170,6 @@
return { room: raw.slice(0, i), item: raw.slice(i + 1) };
}
- function gardenItemHref(prefix, storageUrl) {
- var marker = 'https://slug.social/~/';
- if (storageUrl.indexOf(marker) === 0) {
- var tail = storageUrl.slice(marker.length);
- return prefix.replace(/\/$/, '') + (tail ? '/' + tail : '');
- }
- return storageUrl;
- }
-
function refreshPinHud() {
var hud = document.getElementById('slug-pin-hud');
if (!hud) return;
@@ -187,19 +178,37 @@
var pin = decodePinCookie();
hud.innerHTML = '';
if (!pin || !prefix || pin.room !== bodyRoom) return;
- var a = document.createElement('a');
- a.className = 'slug-pin-hud-link';
- a.href = gardenItemHref(prefix, pin.item);
- a.title = 'Pinned item';
+ var form = document.createElement('form');
+ form.method = 'POST';
+ form.action = '/ui';
+ form.setAttribute('data-navigate', 'full');
+ form.className = 'slug-pin-hud-form';
+ var rpc = document.createElement('input');
+ rpc.type = 'hidden';
+ rpc.name = '__rpc__';
+ rpc.value = JSON.stringify({
+ action: 'set_garden_pin',
+ clear: true,
+ room_wire: '',
+ next: window.location.pathname + window.location.search,
+ form_action: '/ui',
+ });
+ form.appendChild(rpc);
+ var btn = document.createElement('button');
+ btn.type = 'submit';
+ btn.className = 'slug-pin-hud-link slug-pin-hud-unpin-btn';
+ btn.title = 'Unpin — removes this item from the corner HUD';
+ btn.setAttribute('aria-label', 'Unpin pinned item');
var span = document.createElement('span');
span.className = 'slug-pin-hud-glyph';
span.setAttribute('aria-hidden', 'true');
span.textContent = '📌';
- a.appendChild(span);
+ btn.appendChild(span);
var label = pin.item.replace(/^https:\/\/slug\.social\/~\/?/, '~/');
if (label.length > 36) label = label.slice(0, 34) + '…';
- a.appendChild(document.createTextNode(' ' + label));
- hud.appendChild(a);
+ btn.appendChild(document.createTextNode(' ' + label));
+ form.appendChild(btn);
+ hud.appendChild(form);
}
refreshPinHud();
diff --git a/server/static/theme_default.css b/server/static/theme_default.css
index ec0fbe7acee0aa2802f978f14a9b0fc86e78c5b8..9178f0629cfb868348740e6dea1626dc6afb8345 100644
--- a/server/static/theme_default.css
+++ b/server/static/theme_default.css
@@ -799,6 +799,12 @@ details > summary::-webkit-details-marker { display: none; }
}
/* Pinned item HUD — bottom bar, same plane as spread */
+.slug-pin-hud-form {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ border: none;
+}
#slug-pin-hud.slug-pin-hud {
margin-left: auto;
max-width: min(42vw, 280px);
@@ -808,6 +814,13 @@ details > summary::-webkit-details-marker { display: none; }
overflow: hidden;
text-overflow: ellipsis;
}
+.slug-pin-hud-link.slug-pin-hud-unpin-btn {
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font-size: inherit;
+ font-family: inherit;
+}
.slug-pin-hud-link {
color: var(--ui);
text-decoration: none;
@@ -815,7 +828,10 @@ details > summary::-webkit-details-marker { display: none; }
align-items: center;
gap: 4px;
}
-.slug-pin-hud-link:hover { color: var(--signal); }
+.slug-pin-hud-link:hover,
+.slug-pin-hud-unpin-btn:hover {
+ color: var(--signal);
+}
.slug-pin-hud-glyph { font-size: 13px; line-height: 1; }
/* Garden pin / vote controls */
@@ -890,10 +906,21 @@ span.ont-garden-pinned-here {
align-items: center;
justify-content: center;
}
+a.ont-garden-vote-ico {
+ gap: 4px;
+}
a.ont-garden-vote-ico:hover {
color: var(--signal);
background: var(--g4);
}
+.ont-garden-v
… preview truncated; 4,237 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.