B adds genuinely new, tested functionality (typed `$form:i32` template holes with round-trip tests, a post-vote redirect via `next`, and wiring for a new `/vote` route with supporting CSS/JS), which extends the project's real capability. A is a more narrow internal refactor (Deque→Vec for recent_votes) that removes write-time capping (`trim_recent_votes`) in favor of read-time capping, a plausible simplification but one that trades bounded storage growth for simplicity, with less clear net benefit despite the added test.
constitution · epochs · watch · epoch 3
c_a896b2dc05d5 (tommy-mor) vs c_939729b4d9a6 (tommy-mor)
download prompt · raw event · cmp_e199d2b0a0aace
council reasoning
A is a focused core design change: recent_votes moves from capped Deque/VecDeque with write-time trim to append-only List/Vec with query-time cap, drops apply-path parent tracking, bumps schema, and adds a concrete load-cap test. B adds useful vote UX wiring (form :i32 holes, next redirect, slider/CSS, route hooks) but is diluted by Cargo.lock noise and leaves the declared vote/pair modules out of the patch, so less durable substance per line.
Side A makes a substantive storage-model redesign by replacing the durable recent-vote deque with an append-only list, removing post-commit trimming, updating schema versioning, capping history at query time, and adding a focused regression test to verify only the newest 200 votes are loaded. Side B mostly adds UI features (vote page plumbing, typed form holes, navigation, CSS/JS, and dependencies), but despite its size it contains comparatively less core architectural value than A's persistence and data-loading changes.
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_939729b4d9a6 (tommy-mor)
message
[7964b28f] fix
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index 8c43fb75c472b102e6e1d3b837dce3355be898f2..e55d87f32ab32064686431c7082ef8c9ca872d63 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -923,6 +923,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -980,6 +989,36 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -1270,6 +1309,7 @@ dependencies = [
"dotenvy",
"futures-util",
"maud",
+ "rand",
"reqwest",
"serde",
"serde_json",
@@ -1280,6 +1320,7 @@ dependencies = [
"tower-http 0.5.2",
"tracing",
"tracing-subscriber",
+ "urlencoding",
]
[[package]]
@@ -1656,6 +1697,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -2052,6 +2099,26 @@ dependencies = [
"synstructure",
]
+[[package]]
+name = "zerocopy"
+version = "0.8.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "zerofrom"
version = "0.1.8"
diff --git a/server/Cargo.toml b/server/Cargo.toml
index c940acb687fb141d21760a3d6656172013cf6f41..6fb7bf52fa58f46f5e8fb0f7fd395247b57506d7 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -20,6 +20,8 @@ reqwest = { version = "0.12", features = ["json"] }
dotenvy = "0.15"
async-stream = "0.3"
futures-util = { version = "0.3", default-features = false, features = ["std"] }
+rand = "0.8"
+urlencoding = "2"
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index e649a7d192feade465e19ce6187a829f6ec74372..06001212820101e0cc953d3687dea64f85e60787 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -47,6 +47,7 @@ pub async fn post_ui_html(
ratio_left,
ratio_right,
scope,
+ next,
} => {
let parent = parent_from_scope(&scope);
if let Err(e) = state
@@ -56,9 +57,19 @@ pub async fn post_ui_html(
return ui_js_warn(&e).into_response();
}
let tree = state.tree.read().await;
+ if !next.trim().is_empty() {
+ drop(tree);
+ return JsBuilder::new()
+ .raw(&format!(
+ "window.location.href={};",
+ js_string_literal(next.trim())
+ ))
+ .into_response();
+ }
let empty = crate::reducer::NodeState::default();
let node = tree.get(&parent).unwrap_or(&empty);
let panel = ranking_panel(&parent, node, &tree);
+ drop(tree);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
@@ -126,6 +137,7 @@ mod tests {
ratio_left: 3,
ratio_right: 1,
scope: String::new(),
+ next: String::new(),
}
);
}
diff --git a/server/src/form_template.rs b/server/src/form_template.rs
index b9bc3982a125e94e67c99175ea9055979541abba..bd4a7195a6eaabfed55333bab5640708ea108b81 100644
--- a/server/src/form_template.rs
+++ b/server/src/form_template.rs
@@ -7,19 +7,30 @@ pub fn template_json_compact<T: Serialize>(v: &T) -> serde_json::Result<String>
serde_json::to_string(v)
}
-/// Recursively walk the JSON AST and replace `{"$form": "key"}` with the submitted
-/// string for `key` (empty if missing). Other keys are unchanged.
+/// Recursively walk the JSON AST and replace form holes with submitted values.
+///
+/// - `{"$form": "key"}` → string (empty if missing)
+/// - `{"$form:i32": "key"}` → JSON number (0 if missing or unparseable)
pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
match val {
Value::Object(map) => {
if map.len() == 1 {
- if let Some(Value::String(field_name)) = map.get("$form") {
- let submitted = form_data
- .get(field_name.as_str())
- .map(|s| s.as_str())
- .unwrap_or("");
- *val = Value::String(submitted.to_string());
- return;
+ if let Some((hole_key, Value::String(field_name))) = map.iter().next() {
+ if let Some(form_type) = hole_key.strip_prefix("$form") {
+ let submitted = form_data
+ .get(field_name.as_str())
+ .map(|s| s.as_str())
+ .unwrap_or("");
+ *val = match form_type {
+ "" => Value::String(submitted.to_string()),
+ ":i32" => {
+ let n: i32 = submitted.trim().parse().unwrap_or(0);
+ Value::Number(n.into())
+ }
+ _ => Value::String(submitted.to_string()),
+ };
+ return;
+ }
}
}
for v in map.values_mut() {
@@ -62,6 +73,45 @@ mod tests {
text: String,
}
+ #[test]
+ fn i32_holes_become_numbers() {
+ let json = r#"{
+ "ratio_left": {"$form:i32": "ratio_left"},
+ "ratio_right": {"$form:i32": "ratio_right"}
+ }"#;
+ let mut form = HashMap::new();
+ form.insert("ratio_left".into(), "75".into());
+ form.insert("ratio_right".into(), "25".into());
+ let v = fill_template_from_form(json, &form).unwrap();
+ assert_eq!(v["ratio_left"], 75);
+ assert_eq!(v["ratio_right"], 25);
+
+ #[derive(Debug, Deserialize, PartialEq, Eq)]
+ struct Ratios {
+ ratio_left: i32,
+ ratio_right: i32,
+ }
+ let r: Ratios = serde_json::from_value(v).unwrap();
+ assert_eq!(
+ r,
+ Ratios {
+ ratio_left: 75,
+ ratio_right: 25,
+ }
+ );
+ }
+
+ #[test]
+ fn i32_hole_missing_or_bad_defaults_to_zero() {
+ let json = r#"{"n": {"$form:i32": "missing"}}"#;
+ let v = fill_template_from_form(json, &HashMap::new()).unwrap();
+ assert_eq!(v["n"], 0);
+ let mut form = HashMap::new();
+ form.insert("missing".into(), "nope".into());
+ let v = fill_template_from_form(json, &form).unwrap();
+ assert_eq!(v["n"], 0);
+ }
+
#[test]
fn holes_become_strings() {
let json = r#"{
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 3bf9fc7e92e50beed24e2c25106a77421038c90b..61cdbc094819ddedb755572c59456ec0d6617619 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -20,6 +20,8 @@ use crate::{
ui_action::UI_RPC_FIELD,
};
+pub mod vote;
+
const SORTER_CSS: &str = include_str!("../../static/sorter.css");
const SORTER_UI_JS: &str = include_str!("../../static/sorter_ui.js");
@@ -131,7 +133,7 @@ fn layout(title: &str, body: Markup, views: u64) -> Markup {
}
}
-fn item_href(id: &ItemId) -> String {
+pub(crate) fn item_href(id: &ItemId) -> String {
id.browse_href()
}
@@ -309,11 +311,23 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
let empty_node = NodeState::default();
let node = tree.get(&item).unwrap_or(&empty_node);
+ let child_count = node.children.len();
+ let vote_link = if child_count >= 2 {
+ Some(vote::vote_href(&item))
+ } else {
+ None
+ };
+
let body = html! {
h1 { "sorter" }
(input_panel("", None))
(breadcrumb_path(&item))
(entity_section(&item, node, false))
+ @if let Some(href) = vote_link {
+ p class="vote-cta" {
+ a class="btn-primary" href=(href) data-testid="vote-children" { "Vote on children" }
+ }
+ }
(ranking_panel(&item, node, &tree))
};
layout("sorter2", body, views)
diff --git a/server/src/lib.rs b/server/src/lib.rs
index da5f3ebecec1794b05a2a69cc78379551b2ad769..7f7e28c8ac3758de87f1f8e073b24be4132d38da 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,6 +4,7 @@ pub mod events;
pub mod fetch;
pub mod form_template;
pub mod html;
+pub mod pair;
pub mod parser;
pub mod path_types;
pub mod ranking;
@@ -33,6 +34,7 @@ pub fn create_app(state: AppState) -> Router {
.route("/static/:filename", get(crate::html::serve_static))
.route("/~/*item_path", get(crate::html::browse))
.route("/", get(crate::html::home))
+ .route("/vote", get(crate::html::vote::vote_page))
.route("/ui", post(crate::api::ui_html::post_ui_html))
.with_state(state)
.layer(TraceLayer::new_for_http())
diff --git a/server/src/ui_action.rs b/server/src/ui_action.rs
index 2047713762c932ac9bc325fe15624f2aecb3364d..53581e1362bfcc5dfb4ae3069c41ea6f7be41437 100644
--- a/server/src/ui_action.rs
+++ b/server/src/ui_action.rs
@@ -31,6 +31,9 @@ pub enum HtmlUiAction {
/// Parent node [`ItemId`] string; empty = tree root.
#[serde(default)]
scope: String,
+ /// After vote, navigate here (vote compare page).
+ #[serde(default)]
+ next: String,
},
/// Parse pasted Reddit URL/path; redirect to subreddit ranking on success.
ParseQuery {
@@ -70,6 +73,36 @@ pub fn parse_html_ui_from_form(
mod tests {
use super::*;
+ #[test]
+ fn record_vote_round_trip_with_typed_ratio_holes() {
+ let template = serde_json::json!({
+ "action": "record_vote",
+ "a": "x",
+ "b": "y",
+ "ratio_left": {"$form:i32": "ratio_left"},
+ "ratio_right": {"$form:i32": "ratio_right"},
+ "scope": "parent",
+ });
+ let mut form = HashMap::new();
+ form.insert(
+ UI_RPC_FIELD.to_string(),
+ serde_json::to_string(&template).unwrap(),
+ );
+ form.insert("ratio_left".into(), "60".into());
+ form.
… preview truncated; 4,992 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.