{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[1d9d8ade] init seed\n\nSide A — unified diff (full patch):\ndiff --git a/TEST.sh b/TEST.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..266b71f29259f5c2cad2617476ebe2ebc9596d39\n--- /dev/null\n+++ b/TEST.sh\n@@ -0,0 +1,4 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+cargo test --all\n+./scripts/clj-test.sh\ndiff --git a/bundle.js b/bundle.js\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..8d316f9a57cc7c379fe4bd8e42e092c7eac5d265\n--- /dev/null\n+++ b/bundle.js\n@@ -0,0 +1,52 @@\n+/**\n+ * Slug web UI: only plumbing — fetch/eval/SSE. No product UI logic here.\n+ */\n+(function () {\n+ function evalJs(js) {\n+ if (js && String(js).trim()) {\n+ eval(js);\n+ }\n+ }\n+\n+ // Theme cookie sync (runs before paint; full reload if localStorage disagrees with cookie)\n+\n+ function initSlugUi() {\n+ // POST forms → eval response (except theme + full-navigation forms)\n+ document.addEventListener('submit', async function (e) {\n+ var f = e.target;\n+ if (!f || f.tagName !== 'FORM') return;\n+ if ((f.method || 'get').toLowerCase() !== 'post') return;\n+ if (f.id === 'slug-theme-form') return;\n+ if (f.getAttribute('data-navigate') === 'full') return;\n+ e.preventDefault();\n+ var resp = await fetch(f.action, {\n+ method: 'POST',\n+ body: new URLSearchParams(new FormData(f)),\n+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n+ credentials: 'same-origin',\n+ });\n+ evalJs(await resp.text());\n+ });\n+\n+ // SSE: server-pushed JS\n+ function connectSSE() {\n+ var ssePath = window.location.pathname + window.location.search;\n+ var es = new EventSource('/sse?path=' + encodeURIComponent(ssePath));\n+ es.onmessage = function (e) {\n+ evalJs(e.data);\n+ };\n+ es.onerror = function () {\n+ es.close();\n+ setTimeout(connectSSE, 3000);\n+ };\n+ }\n+ connectSSE();\n+ }\n+\n+ if (document.readyState === 'loading') {\n+ document.addEventListener('DOMContentLoaded', initSlugUi);\n+ } else {\n+ initSlugUi();\n+ }\n+})();\n+\ndiff --git a/clj-test.sh b/clj-test.sh\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..b62a49b98ed60a65a46807b7ad80fa3142f14952\n--- /dev/null\n+++ b/clj-test.sh\n@@ -0,0 +1,5 @@\n+#!/usr/bin/env bash\n+set -euo pipefail\n+cd \"$(dirname \"$0\")/..\"\n+mkdir -p target\n+exec clojure -M:kaocha\ndiff --git a/forms.rs b/forms.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d509fd889fde3fed2662ce0a39006b7a51ae762a\n--- /dev/null\n+++ b/forms.rs\n@@ -0,0 +1,145 @@\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/form_template.rs\n+//! Plan2-style JSON templates with `{\"$form\": \"field_name\"}` holes, filled from\n+//! `application/x-www-form-urlencoded` (or any `String` → `String` map) **before**\n+//! deserializing into a typed struct.\n+//!\n+//! # Wire format\n+//!\n+//! Templates are **compact JSON** (`serde_json::to_string`): one line, no pretty\n+//! printing, strings escaped per JSON rules (`\\\"`, `\\n`, etc.). Embed that string\n+//! in HTML attributes or text nodes with normal HTML escaping (e.g. maud), not\n+//! bespoke encodings.\n+//!\n+//! # Power vs flat hidden fields\n+//!\n+//! A form is always a string→string map. You can fake depth with dotted keys (`a.b.c`),\n+//! but one structured blob (`__rpc__` = compact JSON) gives you nested objects,\n+//! arrays, and optional fields without inventing a new naming scheme each time.\n+//!\n+//! # Security\n+//!\n+//! Substitution runs **before** `serde` into your command type. It does not fix\n+//! authorization: if the client can replace the hidden `__rpc__` value, they can\n+//! change the command shape unless you validate (signed blob, server-side session\n+//! context, or treat the blob as hints only). Same threat model as any hidden field.\n+\n+use serde::Serialize;\n+use serde_json::Value;\n+use std::collections::HashMap;\n+\n+/// Serialize a value to compact JSON for a hidden `__rpc__` (or similar) field.\n+pub fn template_json_compact(v: &T) -> serde_json::Result {\n+ serde_json::to_string(v)\n+}\n+\n+/// Recursively walk the JSON AST and replace `{\"$form\": \"key\"}` with the submitted\n+/// string for `key` (empty if missing). Other keys are unchanged.\n+pub fn substitute_form_vars(val: &mut Value, form_data: &HashMap) {\n+ match val {\n+ Value::Object(map) => {\n+ if map.len() == 1 {\n+ if let Some(Value::String(field_name)) = map.get(\"$form\") {\n+ let submitted = form_data\n+ .get(field_name.as_str())\n+ .map(|s| s.as_str())\n+ .unwrap_or(\"\");\n+ *val = Value::String(submitted.to_string());\n+ return;\n+ }\n+ }\n+ for v in map.values_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ Value::Array(arr) => {\n+ for v in arr.iter_mut() {\n+ substitute_form_vars(v, form_data);\n+ }\n+ }\n+ _ => {}\n+ }\n+}\n+\n+/// Parse JSON, apply [`substitute_form_vars`], return the mutated value.\n+pub fn fill_template_from_form(\n+ template_json: &str,\n+ form_data: &HashMap,\n+) -> Result {\n+ let mut v: Value = serde_json::from_str(template_json)?;\n+ substitute_form_vars(&mut v, form_data);\n+ Ok(v)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use serde::Deserialize;\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Demo {\n+ room: String,\n+ thread_tag: String,\n+ nested: Nested,\n+ }\n+\n+ #[derive(Debug, Deserialize, PartialEq, Eq)]\n+ struct Nested {\n+ text: String,\n+ }\n+\n+ #[test]\n+ fn holes_become_strings() {\n+ let json = r#\"{\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"tag\"},\n+ \"nested\": {\"text\": {\"$form\": \"body\"}}\n+ }\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"tag\".into(), \"foo\".into());\n+ form.insert(\"body\".into(), \"hello\\nworld\".into());\n+\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ let d: Demo = serde_json::from_value(v).unwrap();\n+ assert_eq!(\n+ d,\n+ Demo {\n+ room: \"public\".into(),\n+ thread_tag: \"foo\".into(),\n+ nested: Nested {\n+ text: \"hello\\nworld\".into(),\n+ },\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn missing_form_key_is_empty_string() {\n+ let json = r#\"{\"x\": {\"$form\": \"nope\"}}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"other\".into(), \"y\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"x\"], \"\");\n+ }\n+\n+ #[test]\n+ fn array_of_holes() {\n+ let json = r#\"{\"items\": [{\"$form\": \"a\"}, {\"$form\": \"b\"}]}\"#;\n+ let mut form = HashMap::new();\n+ form.insert(\"a\".into(), \"1\".into());\n+ form.insert(\"b\".into(), \"2\".into());\n+ let v = fill_template_from_form(json, &form).unwrap();\n+ assert_eq!(v[\"items\"], serde_json::json!([\"1\", \"2\"]));\n+ }\n+\n+ #[test]\n+ fn template_json_compact_escapes_and_single_line() {\n+ let s = template_json_compact(&serde_json::json!({\n+ \"x\": \"quote\\\"and\\nnewline\"\n+ }))\n+ .unwrap();\n+ assert!(!s.contains('\\n'));\n+ assert!(s.contains(\"\\\\\\\"\") || s.contains(\"\\\\n\"));\n+ }\n+}\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ \n+\ndiff --git a/gameifying.tdsl b/gameifying.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..93d2a61d94fcba4370c82d9612e6bb0f0318cc15\n--- /dev/null\n+++ b/gameifying.tdsl\n@@ -0,0 +1,2 @@\n+consider gating certain views (top all time?) by a 10 day usage streak.. or something like that. \n+or like a path, on homepage(?), that shows day 1: r/amitheasshole, day2: r/aww, day3: gaming, or something like that. progressive revelation + usage incentive.\ndiff --git a/pagerank_streaming.tdsl b/pagerank_streaming.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0f2231b94936c17610adf653ca26b0faa6f2ac3a\n--- /dev/null\n+++ b/pagerank_streaming.tdsl\n@@ -0,0 +1,11 @@\n+eventually i want to have ranking histories.\n+like \"visionary\" and \"fraud\" waxing and waning in a uplot graph over time for #elon-musk. \n+there are too many query combinations to precomupte the ranking histories \n+(arbitrary user filters, and tag overlap combinations maybe), \n+so we're just going to have to calculate them all on demand. \n+computers are fast, its okay. for each vote, we need to calculate rank centrality again. \n+i was thinking, for n votes we calculate the rank centrality, \n+and get node weights. we then output that data to the client (over websocket, or testable barrier). \n+then we calculate n+1 votes, _but we keep the node weights in memory_ \n+so the rank centrality process converges faster. \n+this also has the side effect of making the ranking stream in satisfyingly as you load the page.\ndiff --git a/parser.rs b/parser.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..2b87b974f8d1dd93bee35681d87668a94e4ef349\n--- /dev/null\n+++ b/parser.rs\n@@ -0,0 +1,1808 @@\n+use std::collections::HashMap;\n+use std::rc::Rc;\n+use std::cell::RefCell;\n+use crate::ui::action::UIAction;\n+use crate::ui::types::{Suggestion, GuideOption, ScrollingSuggestion};\n+\n+// --- Core Abstractions ---\n+\n+/// Unique identifier for nodes in the graph\n+type NodeId = &'static str;\n+\n+/// Pattern matching for edges\n+#[derive(Debug, Clone)]\n+pub enum EdgePattern {\n+ /// Matches exact literal string\n+ Literal(&'static str),\n+ \n+ /// Matches any prefix of a string and suggests the full string\n+ /// e.g., PrefixOf(\"reddit.com\") matches \"r\", \"re\", \"red\", \"reddit\", \"reddit.com\"\n+ PrefixOf(&'static str),\n+ \n+ /// Captures a variable segment (e.g., subreddit name, username)\n+ Variable(&'static str),\n+ \n+ /// Matches any string (wildcard)\n+ Any,\n+}\n+\n+impl EdgePattern {\n+ /// Try to match this pattern against input, return (consumed_chars, captured_value)\n+ fn matches(&self, input: &str) -> Option<(usize, Option)> {\n+ match self {\n+ EdgePattern::Literal(lit) => {\n+ if input.starts_with(lit) {\n+ Some((lit.len(), None))\n+ } else {\n+ None\n+ }\n+ }\n+ EdgePattern::PrefixOf(target) => {\n+ // Check if input is a prefix of target\n+ if target.starts_with(input) && !input.is_empty() {\n+ // It's a valid prefix\n+ Some((input.len(), None))\n+ } else if input.starts_with(target) {\n+ // Full match\n+ Some((target.len(), None))\n+ } else {\n+ None\n+ }\n+ }\n+ EdgePattern::Variable(var_name) => {\n+ // Consume until next '/' or end of string\n+ let end = input.find('/').unwrap_or(input.len());\n+ if end > 0 {\n+ let captured = input[..end].to_string();\n+ // Validate based on variable type\n+ if is_valid_variable(var_name, &captured) {\n+ Some((end, Some(captured)))\n+ } else {\n+ None\n+ }\n+ } else {\n+ None\n+ }\n+ }\n+ EdgePattern::Any => {\n+ // Match everything until next '/' or end\n+ let end = input.find('/').unwrap_or(input.len());\n+ if end > 0 {\n+ Some((end, Some(input[..end].to_string())))\n+ } else {\n+ None\n+ }\n+ }\n+ }\n+ }\n+ \n+ /// Get the completion suggestion for this pattern\n+ fn completion(&self, partial: &str) -> Option {\n+ match self {\n+ EdgePattern::PrefixOf(target) => {\n+ if target.starts_with(partial) && partial != *target {\n+ Some(target.to_string())\n+ } else {\n+ None\n+ }\n+ }\n+ _ => None,\n+ }\n+ }\n+}\n+\n+/// Edge in the graph\n+pub struct Edge {\n+ pattern: EdgePattern,\n+ target: NodeId,\n+ /// Optional description for autocomplete\n+ description: Option<&'static str>,\n+}\n+\n+/// Handler function for generating UI actions\n+type Handler = Box) -> UIAction>;\n+\n+/// Node in the graph\n+pub struct Node {\n+ #[allow(dead_code)]\n+ id: NodeId,\n+ edges: Vec,\n+ handler: Option,\n+}\n+\n+/// The composable parser graph\n+pub struct Graph {\n+ nodes: HashMap>>,\n+ root: NodeId,\n+}\n+\n+// --- Graph Builder (Fluent API) ---\n+\n+pub struct GraphBuilder {\n+ nodes: HashMap>>,\n+ current_node: Option,\n+ root: NodeId,\n+}\n+\n+impl GraphBuilder {\n+ pub fn new() -> Self {\n+ let mut nodes = HashMap::new();\n+ let root_node = Rc::new(RefCell::new(Node {\n+ id: \"root\",\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ nodes.insert(\"root\", root_node);\n+ \n+ GraphBuilder {\n+ nodes,\n+ current_node: Some(\"root\"),\n+ root: \"root\",\n+ }\n+ }\n+ \n+ /// Select a node to add edges to\n+ pub fn at(mut self, node_id: NodeId) -> Self {\n+ // Create node if it doesn't exist\n+ if !self.nodes.contains_key(node_id) {\n+ let node = Rc::new(RefCell::new(Node {\n+ id: node_id,\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ self.nodes.insert(node_id, node);\n+ }\n+ self.current_node = Some(node_id);\n+ self\n+ }\n+ \n+ /// Add an edge from the current node\n+ pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {\n+ self.edge_with_desc(pattern, target, None)\n+ }\n+ \n+ /// Add an edge with description\n+ pub fn edge_with_desc(mut self, pattern: EdgePattern, target: NodeId, desc: Option<&'static str>) -> Self {\n+ let current = self.current_node.expect(\"No current node selected\");\n+ \n+ // Create target node if it doesn't exist\n+ if !self.nodes.contains_key(target) {\n+ let node = Rc::new(RefCell::new(Node {\n+ id: target,\n+ edges: Vec::new(),\n+ handler: None,\n+ }));\n+ self.nodes.insert(target, node);\n+ }\n+ \n+ // Add edge to current node\n+ if let Some(node) = self.nodes.get(current) {\n+ node.borrow_mut().edges.push(Edge {\n+ pattern,\n+ target,\n+ description: desc,\n+ });\n+ }\n+ \n+ self\n+ }\n+ \n+ /// Set handler for current node\n+ pub fn handler(self, handler: F) -> Self \n+ where \n+ F: Fn(&str, &str, &HashMap) -> UIAction + 'static\n+ {\n+ let current = self.current_node.expect(\"No current node selected\");\n+ if let Some(node) = self.nodes.get(current) {\n+ node.borrow_mut().handler = Some(Box::new(handler));\n+ }\n+ self\n+ }\n+ \n+ /// Build the final graph\n+ pub fn build(self) -> Graph {\n+ Graph {\n+ nodes: self.nodes,\n+ root: self.root,\n+ }\n+ }\n+}\n+\n+// --- Parser Implementation ---\n+\n+impl Graph {\n+ pub fn parse(&self, input: &str) -> UIAction {\n+ let normalized = input.trim().to_lowercase();\n+ let mut state = ParserState {\n+ input: &normalized,\n+ cursor: 0,\n+ current_node_id: self.root,\n+ context: HashMap::new(),\n+ original_query: input.to_string(),\n+ current_prefix: String::new(),\n+ };\n+ \n+ self.parse_recursive(&mut state)\n+ }\n+ \n+ fn parse_recursive(&self, state: &mut ParserState) -> UIAction {\n+ let node = self.nodes.get(state.current_node_id)\n+ .expect(\"Node not found in graph\");\n+ let node_ref = node.borrow();\n+ \n+ // If we've consumed all input, check for handler or suggestions\n+ if state.cursor >= state.input.len() {\n+ if let Some(handler) = &node_ref.handler {\n+ return handler(&state.original_query, &state.current_prefix, &state.context);\n+ }\n+ \n+ // No handler, try to suggest based on available edges\n+ return self.suggest_from_edges(&node_ref, state);\n+ }\n+ \n+ let remaining = &state.input[state.cursor..];\n+ \n+ // Try to match each edge\n+ for edge in &node_ref.edges {\n+ if let Some((consumed, captured)) = edge.pattern.matches(remaining) {\n+ // Save state for potential backtracking\n+ let saved_cursor = state.cursor;\n+ let saved_node = state.current_node_id;\n+ let saved_prefix = state.current_prefix.clone();\n+ \n+ // Update state\n+ state.cursor += consumed;\n+ state.current_node_id = edge.target;\n+ state.current_prefix.push_str(&remaining[..consumed]);\n+ \n+ // Store captured variable if any\n+ if let Some(value) = captured {\n+ if let EdgePattern::Variable(var_name) = &edge.pattern {\n+ state.context.insert(var_name.to_string(), value);\n+ }\n+ }\n+ \n+ // Check if this is a partial match that needs completion\n+ if state.cursor == state.input.len() {\n+ if let Some(completion_suffix) = edge.pattern.completion(remaining) {\n+ // Use the current_prefix plus the completion suffix\n+ let full_completion = format!(\"{}{}\", \n+ state.current_prefix, \n+ completion_suffix.strip_prefix(remaining).unwrap_or(&completion_suffix)\n+ );\n+ return UIAction::suggest(\n+ state.original_query.clone(),\n+ Some(Suggestion {\n+ text: full_completion.clone(),\n+ completion: full_completion,\n+ description: edge.description.map(|d| d.to_string()),\n+ score: 1.0,\n+ })\n+ );\n+ }\n+ }\n+ \n+ // Continue parsing from the target node\n+ let result = self.parse_recursive(state);\n+ \n+ // If we got a valid response, return it\n+ if !matches!(result, UIAction::ShowError(_)) {\n+ return result;\n+ }\n+ \n+ // Otherwise, restore state and try next edge\n+ state.cursor = saved_cursor;\n+ state.current_node_id = saved_node;\n+ state.current_prefix = saved_prefix;\n+ }\n+ }\n+ \n+ // No edges matched - try to provide suggestions\n+ self.suggest_from_edges(&node_ref, state)\n+ }\n+ \n+ fn suggest_from_edges(&self, node: &Node, state: &ParserState) -> UIAction {\n+ let remaining = &state.input[state.cursor..];\n+ \n+ // Find edges that could match with more input\n+ for edge in &node.edges {\n+ match &edge.pattern {\n+ EdgePattern::PrefixOf(target) => {\n+ if target.starts_with(remaining) && !remaining.is_empty() {\n+ // Use current_prefix instead of rebuilding from input\n+ let full_completion = format!(\"{}{}\", state.current_prefix, target);\n+ return UIAction::suggest(\n+ state.original_query.clone(),\n+ Some(Suggestion {\n+ text: full_completion.clone(),\n+ completion: full_completion,\n+ description: edge.description.map(|d| d.to_string()),\n+ score: 1.0,\n+ })\n+ );\n+ }\n+ }\n+ EdgePattern::Literal(lit) => {\n+ if lit.starts_with(remaining) && !remaining.is_empty() {\n+ let full_completion = format!(\"{}{}\", state.current_prefix, lit);\n+ return UIAction::suggest(\n+ state.original_query.clone(),\n+ Some(Suggestion {\n+ text: full_completion.clone(),\n+ completion: full_completion,\n+ description: edge.description.map(|d| d.to_string()),\n+ score: 1.0,\n+ })\n+ );\n+ }\n+ }\n+ _ => {}\n+ }\n+ }\n+ \n+ UIAction::error(\n+ \"InvalidPath\".to_string(),\n+ format!(\"'{}' doesn't match any known pattern\", state.original_query)\n+ )\n+ }\n+}\n+\n+struct ParserState<'a> {\n+ input: &'a str,\n+ cursor: usize,\n+ current_node_id: NodeId,\n+ context: HashMap,\n+ original_query: String,\n+ current_prefix: String,\n+}\n+\n+// --- Helper Functions ---\n+\n+fn is_valid_variable(var_name: &str, value: &str) -> bool {\n+ match var_name {\n+ \"subreddit\" => {\n+ !value.is_empty() && \n+ value.len() <= 21 && \n+ value.chars().all(|c| c.is_alphanumeric() || c == '_')\n+ }\n+ \"username\" => {\n+ !value.is_empty() && \n+ value.len() <= 20 && \n+ value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-')\n+ }\n+ \"post_id\" => {\n+ !value.is_empty() && \n+ value.len() <= 10 && \n+ value.chars().all(|c| c.is_alphanumeric())\n+ }\n+ _ => true, // Allow any value for unknown variables\n+ }\n+}\n+\n+// --- Define the Reddit Graph ---\n+\n+pub fn build_reddit_graph() -> Graph {\n+ GraphBuilder::new()\n+ // === ROOT LEVEL: Direct aliases and domain/protocol patterns ===\n+ .at(\"root\")\n+ // Direct aliases to subreddit and user selection\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"r/\"),\n+ \"subreddit_selection\",\n+ Some(\"Browse subreddits (e.g., r/programming)\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"u/\"),\n+ \"user_selection\",\n+ Some(\"Browse users (e.g., u/spez)\")\n+ )\n+ \n+ // Reddit shortcuts - one pattern handles ALL prefixes!\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"), \n+ \"reddit_domain\",\n+ Some(\"Go to Reddit\")\n+ )\n+ \n+ // Protocol patterns - \"h\" can suggest \"https://\"\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"https://\"),\n+ \"https_protocol\",\n+ Some(\"HTTPS protocol\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"http://\"),\n+ \"http_protocol\", \n+ Some(\"HTTP protocol\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"www.\"),\n+ \"www_prefix\",\n+ Some(\"World Wide Web\")\n+ )\n+ \n+\n+ \n+ // === HTTPS PROTOCOL: Can go to any domain ===\n+ .at(\"https_protocol\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit (HTTPS)\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"www.\"),\n+ \"https_www\",\n+ Some(\"WWW prefix\")\n+ )\n+ \n+ // === HTTP PROTOCOL: Similar to HTTPS ===\n+ .at(\"http_protocol\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit (HTTP)\")\n+ )\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"www.\"),\n+ \"http_www\",\n+ Some(\"WWW prefix\")\n+ )\n+ \n+ // === HTTPS + WWW ===\n+ .at(\"https_www\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit\")\n+ )\n+ \n+ // === HTTP + WWW ===\n+ .at(\"http_www\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit\")\n+ )\n+ \n+ // === WWW PREFIX (without protocol) ===\n+ .at(\"www_prefix\")\n+ .edge_with_desc(\n+ EdgePattern::PrefixOf(\"reddit.com\"),\n+ \"reddit_domain\",\n+ Some(\"Reddit\")\n+ )\n+ \n+ // === REDDIT DOMAIN: Expect \"/\" ===\n+ .at(\"reddit_domain\")\n+ .edge(EdgePattern::Literal(\"/\"), \"reddit_root\")\n+ .handler(|query, prefix, _ctx| {\n+ // If someone just types \"reddit.com\" (or with protocol) without slash\n+ // Suggest adding the slash using the current prefix\n+ let completion = format!(\"{}/\", prefix);\n+ \n+ UIAction::suggest(\n+ query.to_string(),\n+ Some(Suggestion {\n+ text: completion.clone(),\n+ completion,\n+ description: Some(\"Continue to Reddit homepage\".to_string()),\n+ score: 1.0,\n+ })\n+ )\n+ })\n+ \n+ // === REDDIT ROOT: The main Reddit navigation ===\n+ .at(\"reddit_root\")\n+ .edge(EdgePattern::Literal(\"r/\"), \"subreddit_selection\")\n+ .edge(EdgePattern::Literal(\"u/\"), \"user_selection\")\n+ .handler(|query, prefix, _ctx| {\n+ UIAction::multiple(vec![\n+ UIAction::scrolling_suggestions(\n+ query.to_string(),\n+ vec![\n+ ScrollingSuggestion {\n+ completion: format!(\"{}r/\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}u/\", prefix),\n+ },\n+ ],\n+ 1400, // 1.4 second interval (slower)\n+ true // loop through\n+ ),\n+ UIAction::guide(\n+ query.to_string(),\n+ \"Welcome to Sorter for Reddit\".to_string(),\n+ \"Where would you like to start?\".to_string(),\n+ vec![\n+ GuideOption { \n+ key: \"r\".to_string(), \n+ label: \"Sort a Subreddit\".to_string(), \n+ description: \"Find the best posts in a community.\".to_string(), \n+ completion: format!(\"{}r/\", prefix)\n+ },\n+ GuideOption { \n+ key: \"u\".to_string(), \n+ label: \"Sort User Content\".to_string(), \n+ description: \"Explore and rank a user's posts and comments.\".to_string(), \n+ completion: format!(\"{}u/\", prefix)\n+ },\n+ ]\n+ ),\n+ ])\n+ })\n+ \n+\n+ \n+ // === SUBREDDIT SELECTION: THE UNIFIED NODE ===\n+ // This node is now reached from `r/` OR `reddit.com/r/`\n+ .at(\"subreddit_selection\")\n+ .edge(EdgePattern::Variable(\"subreddit\"), \"subreddit_page\")\n+ .handler(|query, prefix, _ctx| {\n+ UIAction::multiple(vec![\n+ UIAction::scrolling_suggestions(\n+ query.to_string(),\n+ vec![\n+ ScrollingSuggestion {\n+ completion: format!(\"{}programming\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}askreddit\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}aww\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}rust\", prefix),\n+ },\n+ ScrollingSuggestion {\n+ completion: format!(\"{}webdev\", prefix),\n+ },\n+ ],\n+ 1600, // 1.6 second interval (slower)\n+ true // loop through\n+ ),\n+ // Live DB-backed suggestions for subreddits as the user types\n+ UIAction::SuggestSubredditsFromDb { partial: query.to_string(), prefix: prefix.to_string() }\n+ ])\n+ })\n+ \n+ // === Specific subreddit page ===\n+ .at(\"subreddit_page\")\n+ .edge(EdgePattern::Literal(\"/\"), \"subreddit_slash\")\n+ .handler(|_query, prefix, ctx| {\n+ // Use the new unified subreddit resolution logic\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::ResolveAndDisplaySubreddit {\n+ subreddit,\n+ prefix: prefix.to_string(),\n+ }\n+ })\n+ \n+ .at(\"subreddit_slash\")\n+ .edge(EdgePattern::Literal(\"hot\"), \"subreddit_hot\")\n+ .edge(EdgePattern::Literal(\"top\"), \"subreddit_top\")\n+ .edge(EdgePattern::Literal(\"new\"), \"subreddit_new\")\n+ .edge(EdgePattern::Literal(\"comments\"), \"subreddit_comments\")\n+ .handler(|query, prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::guide(\n+ query.to_string(), \n+ format!(\"What to sort in r/{}?\", subreddit), \n+ \"Choose a category to begin sorting.\".to_string(), \n+ vec![\n+ GuideOption { \n+ key: \"hot\".to_string(), \n+ label: \"Hot Posts\".to_string(), \n+ description: \"Import and sort posts currently on the front page.\".to_string(), \n+ completion: format!(\"{}hot\", prefix) \n+ },\n+ GuideOption { \n+ key: \"top\".to_string(), \n+ label: \"Top Posts\".to_string(), \n+ description: \"Import and sort the highest-rated posts.\".to_string(), \n+ completion: format!(\"{}top\", prefix) \n+ },\n+ GuideOption { \n+ key: \"new\".to_string(), \n+ label: \"New Posts\".to_string(), \n+ description: \"Import and sort the newest posts.\".to_string(), \n+ completion: format!(\"{}new\", prefix) \n+ },\n+ GuideOption { \n+ key: \"comments\".to_string(), \n+ label: \"All Comments\".to_string(), \n+ description: \"Find the best comment across all imported threads.\".to_string(), \n+ completion: format!(\"{}comments/\", prefix) \n+ },\n+ ]\n+ )\n+ })\n+ \n+\n+ \n+ .at(\"user_selection\")\n+ .edge(EdgePattern::Variable(\"username\"), \"user_profile\")\n+ .handler(|query, prefix, _ctx| {\n+ UIAction::guide(\n+ query.to_string(),\n+ \"User Profile Sorting\".to_string(),\n+ \"Enter a Reddit username to sort their content.\".to_string(),\n+ vec![\n+ GuideOption {\n+ key: \"popular\".to_string(),\n+ label: \"Popular Users\".to_string(),\n+ description: \"Browse well-known Reddit users.\".to_string(),\n+ completion: prefix.to_string(),\n+ },\n+ ]\n+ )\n+ })\n+ \n+ // === Subreddit sort types ===\n+ .at(\"subreddit_hot\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ .at(\"subreddit_top\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ .at(\"subreddit_new\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ .at(\"subreddit_comments\")\n+ .handler(|_query, _prefix, ctx| {\n+ let subreddit = ctx.get(\"subreddit\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.subreddit\".to_string(),\n+ pk: subreddit,\n+ }\n+ })\n+ \n+ // === User profile ===\n+ .at(\"user_profile\")\n+ .handler(|_query, _prefix, ctx| {\n+ let username = ctx.get(\"username\").cloned().unwrap_or_default();\n+ UIAction::RenderEntityView {\n+ ns: \"reddit.user\".to_string(),\n+ pk: username,\n+ }\n+ })\n+ \n+ // === Build the graph ===\n+ .build()\n+}\n+\n+// --- Public API ---\n+\n+/// Parse a query string and return a UI action\n+pub fn parse_reddit_url(query: &str) -> UIAction {\n+ let graph = build_reddit_graph();\n+ graph.parse(query)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ /// Represents a keystroke action\n+ #[derive(Debug, Clone, PartialEq)]\n+ enum KeyAction {\n+ Type(String), // Type characters\n+ Tab, // Press tab (accept completion)\n+\n+ }\n+\n+ /// Expected state after a keystroke\n+ #[derive(Debug, Clone)]\n+ enum ExpectedAction {\n+ Suggestion { completion: String },\n+ ScrollingSuggestions { completions: Vec },\n+ Guide { title_contains: String },\n+\n+ RenderSubreddit { subreddit: String, sort: Option },\n+ RenderSubredditComments { subreddit: String },\n+ RenderUser { username: String },\n+ ResolveSubreddit { subreddit: String, prefix: String }, // New unified subreddit resolution\n+ Error { error_type: String },\n+ Multiple { expected_actions: Vec }, // Multiple responses with specific expectations\n+ MultipleAny, // Multiple responses (any sub-actions - legacy)\n+ DbSuggestions { partial: String, prefix: String }, // Database-backed suggestions\n+\n+ }\n+\n+ impl ExpectedAction {\n+ fn matches(&self, action: &UIAction) -> bool {\n+ match (self, action) {\n+ (ExpectedAction::Suggestion { completion }, UIAction::ShowSuggestions(data)) => {\n+ data.suggestion.as_ref()\n+ .map(|s| s.completion == *completion)\n+ .unwrap_or(false)\n+ }\n+ (ExpectedAction::ScrollingSuggestions { completions }, UIAction::ShowScrollingSuggestions { suggestions, .. }) => {\n+ let actual_completions: Vec = suggestions.iter().map(|s| s.completion.clone()).collect();\n+ *completions == actual_completions\n+ }\n+ (ExpectedAction::Guide { title_contains }, UIAction::ShowStaticGuide { title, .. }) => {\n+ title.contains(title_contains)\n+ }\n+ (ExpectedAction::RenderSubreddit { subreddit, sort: _ }, \n+ UIAction::RenderEntityView { ns, pk }) => {\n+ ns == \"reddit.subreddit\" && pk == subreddit\n+ }\n+ (ExpectedAction::RenderSubredditComments { subreddit }, \n+ UIAction::RenderEntityView { ns, pk }) => {\n+ ns == \"reddit.subreddit\" && pk == subreddit\n+ }\n+ (ExpectedAction::RenderUser { username }, UIAction::RenderEntityView { ns, pk }) => {\n+ ns == \"reddit.user\" && pk == username\n+ }\n+ (ExpectedAction::ResolveSubreddit { subreddit, prefix }, \n+ UIAction::ResolveAndDisplaySubreddit { subreddit: s, prefix: p }) => {\n+ s == subreddit && p == prefix\n+ }\n+ (ExpectedAction::Error { error_type }, UIAction::ShowError(data)) => {\n+ data.error_type == *error_type\n+ }\n+ (ExpectedAction::Multiple { expected_actions }, UIAction::ShowMultiple { actions }) => {\n+ // Check that all expected actions are present\n+ if expected_actions.len() != actions.len() {\n+ return false;\n+ }\n+ expected_actions.iter().zip(actions.iter()).all(|(expected, actual)| {\n+ expected.matches(actual)\n+ })\n+ }\n+ (ExpectedAction::MultipleAny, UIAction::ShowMultiple { .. }) => true,\n+ (ExpectedAction::DbSuggestions { partial, prefix }, \n+ UIAction::SuggestSubredditsFromDb { partial: p, prefix: pr }) => {\n+ p == partial && pr == prefix\n+ }\n+\n+ _ => false,\n+ }\n+ }\n+ }\n+\n+ /// Test helper to simulate a sequence of keystrokes\n+ fn simulate_keystrokes(actions: Vec) -> Vec<(String, UIAction)> {\n+ let graph = build_reddit_graph();\n+ let mut current_text = String::new();\n+ let mut results = Vec::new();\n+ \n+ for action in actions {\n+ match action {\n+ KeyAction::Type(text) => {\n+ current_text.push_str(&text);\n+ let result = graph.parse(¤t_text);\n+ results.push((current_text.clone(), result));\n+ }\n+ KeyAction::Tab => {\n+ // Tab accepts the current suggestion if there is one\n+ let result = graph.parse(¤t_text);\n+ if let UIAction::ShowSuggestions(ref data) = result {\n+ if let Some(ref suggestion) = data.suggestion {\n+ current_text = suggestion.completion.clone();\n+ let new_result = graph.parse(¤t_text);\n+ results.push((current_text.clone(), new_result));\n+ }\n+ }\n+ }\n+\n+ }\n+ }\n+ \n+ results\n+ }\n+\n+ /// Test a flow using declarative (KeyAction, ExpectedAction) tuples\n+ fn test_flow(name: &str, flow: Vec<(KeyAction, ExpectedAction)>) {\n+ let graph = build_reddit_graph();\n+ let mut current_text = String::new();\n+ \n+ println!(\"\\n=== Flow: {} ===\", name);\n+ \n+ for (i, (key_action, expected)) in flow.iter().enumerate() {\n+ // Perform the keystroke\n+ match key_action {\n+ KeyAction::Type(text) => {\n+ current_text.push_str(text);\n+ }\n+ KeyAction::Tab => {\n+ // Tab accepts the current suggestion\n+ let result = graph.parse(¤t_text);\n+ if let UIAction::ShowSuggestions(data) = result {\n+ if let Some(suggestion) = &data.suggestion {\n+ current_text = suggestion.completion.clone();\n+ }\n+ }\n+ }\n+ }\n+ \n+ // Check the result\n+ let actual_action = graph.parse(¤t_text);\n+ \n+ println!(\" Step {}: {:?} -> '{}' -> {:?}\", \n+ i + 1, key_action, current_text, actual_action);\n+ \n+ assert!(\n+ expected.matches(&actual_action),\n+ \"Flow '{}' failed at step {}\\n Expected: {:?}\\n Actual: {:?}\\n Text: '{}'\",\n+ name, i + 1, expected, actual_action, current_text\n+ );\n+ }\n+ \n+ println!(\"✓ Flow '{}' passed!\", name);\n+ }\n+ \n+ /// Legacy helper for backward compatibility (will be removed)\n+ fn assert_flow(\n+ name: &str,\n+ actions: Vec,\n+ expected_checks: Vec bool>>,\n+ ) {\n+ let results = simulate_keystrokes(actions);\n+ \n+ println!(\"\\n=== Flow: {} ===\", name);\n+ for (i, (text, action)) in results.iter().enumerate() {\n+ println!(\" Step {}: '{}' -> {:?}\", i + 1, text, action);\n+ \n+ if i < expected_checks.len() {\n+ let check = &expected_checks[i];\n+ assert!(\n+ check(text, action),\n+ \"Flow '{}' failed at step {} with text '{}' and action {:?}\",\n+ name, i + 1, text, action\n+ );\n+ }\n+ }\n+ println!(\"✓ Flow '{}' passed!\", name);\n+ }\n+\n+ #[test]\n+ fn test_https_reddit_tab_flow() {\n+ // Test: typing \"https://reddit.com\" and pressing tab should give \"https://reddit.com/\"\n+ assert_flow(\n+ \"HTTPS Reddit with Tab\",\n+ vec![\n+ KeyAction::Type(\"https://reddit.com\".to_string()),\n+ KeyAction::Tab,\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // After typing \"https://reddit.com\", should get a suggestion\n+ text == \"https://reddit.com\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com/\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should have \"https://reddit.com/\" and show guide\n+ text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_quick_subreddit_flow() {\n+ // Test: \"r\" -> TAB -> \"rust\" (direct alias flow)\n+ assert_flow(\n+ \"Quick Subreddit Access\",\n+ vec![\n+ KeyAction::Type(\"r\".to_string()),\n+ KeyAction::Tab,\n+ KeyAction::Type(\"rust\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // \"r\" should suggest \"r/\"\n+ text == \"r\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"r/\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should have \"r/\" and show subreddit selection\n+ text == \"r/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"r/rust\" should resolve the subreddit\n+ text == \"r/rust\" && matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"rust\" && prefix == \"r/rust\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_progressive_completion_flow() {\n+ // Test progressive typing: \"r\" -> \"re\" -> \"red\" -> \"redd\" -> \"reddit\" -> TAB\n+ let progressive_actions = vec![\n+ KeyAction::Type(\"r\".to_string()),\n+ KeyAction::Type(\"e\".to_string()),\n+ KeyAction::Type(\"d\".to_string()),\n+ KeyAction::Type(\"d\".to_string()),\n+ KeyAction::Type(\"i\".to_string()),\n+ KeyAction::Type(\"t\".to_string()),\n+ KeyAction::Tab,\n+ ];\n+ \n+ let results = simulate_keystrokes(progressive_actions);\n+ \n+ println!(\"\\n=== Progressive Completion Flow ===\");\n+ for (i, (text, action)) in results.iter().enumerate() {\n+ println!(\" '{}' -> {:?}\", text, action);\n+ \n+ // First step: \"r\" should suggest \"r/\"\n+ if i == 0 && text == \"r\" {\n+ match action {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(\n+ data.suggestion.as_ref().unwrap().completion,\n+ \"r/\",\n+ \"Should suggest r/ at 'r'\"\n+ );\n+ }\n+ _ => panic!(\"Expected suggestion at 'r'\"),\n+ }\n+ }\n+ // Other steps before tab should suggest \"reddit.com\"\n+ else if i > 0 && i < results.len() - 1 {\n+ match action {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(\n+ data.suggestion.as_ref().unwrap().completion,\n+ \"reddit.com\",\n+ \"Should suggest reddit.com at '{}'\", text\n+ );\n+ }\n+ _ => panic!(\"Expected suggestion at '{}'\", text),\n+ }\n+ }\n+ }\n+ \n+ // After tab, should have \"reddit.com\"\n+ let (final_text, _) = results.last().unwrap();\n+ assert_eq!(final_text, \"reddit.com\");\n+ println!(\"✓ Progressive completion flow passed!\");\n+ }\n+\n+ #[test]\n+ fn test_subreddit_sort_flow() {\n+ // Test navigating to a subreddit and choosing a sort option\n+ assert_flow(\n+ \"Subreddit Sort Navigation\",\n+ vec![\n+ KeyAction::Type(\"reddit.com/r/programming/hot\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"reddit.com/r/programming/hot\" && \n+ matches!(action, UIAction::RenderEntityView { ns, pk } \n+ if ns == \"reddit.subreddit\" && pk == \"programming\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_user_profile_flow() {\n+ // Test navigating to a user profile\n+ assert_flow(\n+ \"User Profile Navigation\",\n+ vec![\n+ KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"reddit.com/u/spez\" && \n+ matches!(action, UIAction::RenderEntityView { ns, pk } if ns == \"reddit.user\" && pk == \"spez\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_alias_shortcut_flow() {\n+ // Test using the \"r/\" shortcut - now correctly goes directly to subreddit\n+ assert_flow(\n+ \"Alias Shortcut\",\n+ vec![\n+ KeyAction::Type(\"r/\".to_string()),\n+ KeyAction::Type(\"technology\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"r/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ text == \"r/technology\" && \n+ matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } if subreddit == \"technology\" && prefix == \"r/technology\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_www_prefix_flow() {\n+ // Test with www prefix\n+ assert_flow(\n+ \"WWW Prefix\",\n+ vec![\n+ KeyAction::Type(\"www.reddit.com\".to_string()),\n+ KeyAction::Tab,\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // Should suggest adding slash\n+ text == \"www.reddit.com\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"www.reddit.com/\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should show guide\n+ text == \"www.reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_invalid_path_handling() {\n+ // Test that invalid paths show errors\n+ assert_flow(\n+ \"Invalid Path\",\n+ vec![\n+ KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ text == \"reddit.com/invalid/path\" && \n+ matches!(action, UIAction::ShowError(_))\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_complete_user_journey() {\n+ // Test a complete user journey: type partial URL, tab complete, navigate to subreddit\n+ assert_flow(\n+ \"Complete User Journey\",\n+ vec![\n+ KeyAction::Type(\"http\".to_string()),\n+ KeyAction::Type(\"s://r\".to_string()),\n+ KeyAction::Tab,\n+ KeyAction::Type(\"/\".to_string()),\n+ KeyAction::Type(\"r/\".to_string()),\n+ KeyAction::Type(\"programming\".to_string()),\n+ KeyAction::Type(\"/\".to_string()),\n+ KeyAction::Type(\"top\".to_string()),\n+ ],\n+ vec![\n+ Box::new(|text, action| {\n+ // \"http\" should suggest \"https://\"\n+ text == \"http\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"https://\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://r\" should suggest \"https://reddit.com\"\n+ text == \"https://r\" && matches!(action, UIAction::ShowSuggestions(data) if \n+ data.suggestion.as_ref().map(|s| s.completion == \"https://reddit.com\").unwrap_or(false)\n+ )\n+ }),\n+ Box::new(|text, action| {\n+ // After tab, should have \"https://reddit.com\"\n+ text == \"https://reddit.com\" && matches!(action, UIAction::ShowSuggestions(_))\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/\" should show guide\n+ text == \"https://reddit.com/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/\" should show subreddit selection\n+ text == \"https://reddit.com/r/\" && matches!(action, UIAction::ShowMultiple { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/programming\" should resolve subreddit\n+ text == \"https://reddit.com/r/programming\" && \n+ matches!(action, UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } \n+ if subreddit == \"programming\" && prefix == \"https://reddit.com/r/programming\")\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/programming/\" should show sort options\n+ text == \"https://reddit.com/r/programming/\" && \n+ matches!(action, UIAction::ShowStaticGuide { .. })\n+ }),\n+ Box::new(|text, action| {\n+ // \"https://reddit.com/r/programming/top\" should render entity view\n+ text == \"https://reddit.com/r/programming/top\" && \n+ matches!(action, UIAction::RenderEntityView { ns, pk } \n+ if ns == \"reddit.subreddit\" && pk == \"programming\")\n+ }),\n+ ],\n+ );\n+ }\n+\n+ #[test]\n+ fn test_multiple_tab_completions() {\n+ // Test multiple tab completions in sequence\n+ let actions = vec![\n+ KeyAction::Type(\"h\".to_string()),\n+ KeyAction::Tab, // Complete to \"https://\"\n+ KeyAction::Type(\"r\".to_string()),\n+ KeyAction::Tab, // Complete to \"https://reddit.com\"\n+ KeyAction::Type(\"/\".to_string()),\n+ ];\n+ \n+ let results = simulate_keystrokes(actions);\n+ \n+ println!(\"\\n=== Multiple Tab Completions ===\");\n+ for (i, (text, _action)) in results.iter().enumerate() {\n+ println!(\" Step {}: '{}'\", i + 1, text);\n+ }\n+ \n+ // Verify the final state\n+ assert_eq!(results[1].0, \"https://\"); // After first tab\n+ assert_eq!(results[3].0, \"https://reddit.com\"); // After second tab\n+ assert_eq!(results[4].0, \"https://reddit.com/\"); // After typing /\n+ \n+ println!(\"✓ Multiple tab completions work correctly!\");\n+ }\n+\n+ // === CONVENIENCE MACROS FOR CLEANER TESTS ===\n+ \n+ macro_rules! flow {\n+ ($(($key:expr, $expected:expr)),* $(,)?) => {\n+ vec![$(($key, $expected)),*]\n+ };\n+ }\n+ \n+ macro_rules! type_text {\n+ ($text:expr) => {\n+ KeyAction::Type($text.to_string())\n+ };\n+ }\n+ \n+ macro_rules! suggests {\n+ ($completion:expr) => {\n+ ExpectedAction::Suggestion { completion: $completion.to_string() }\n+ };\n+ }\n+ \n+ macro_rules! renders_subreddit {\n+ ($subreddit:expr) => {\n+ ExpectedAction::RenderSubreddit { subreddit: $subreddit.to_string(), sort: None }\n+ };\n+ ($subreddit:expr, $sort:expr) => {\n+ ExpectedAction::RenderSubreddit { \n+ subreddit: $subreddit.to_string(), \n+ sort: Some($sort.to_string()) \n+ }\n+ };\n+ }\n+ \n+ macro_rules! renders_subreddit_comments {\n+ ($subreddit:expr) => {\n+ ExpectedAction::RenderSubredditComments { subreddit: $subreddit.to_string() }\n+ };\n+ }\n+ \n+ macro_rules! resolves_subreddit {\n+ ($subreddit:expr, $prefix:expr) => {\n+ ExpectedAction::ResolveSubreddit { \n+ subreddit: $subreddit.to_string(),\n+ prefix: $prefix.to_string()\n+ }\n+ };\n+ }\n+ \n+ macro_rules! shows_guide {\n+ ($title_contains:expr) => {\n+ ExpectedAction::Guide { title_contains: $title_contains.to_string() }\n+ };\n+ }\n+ \n+ macro_rules! multiple {\n+ ($($action:expr),* $(,)?) => {\n+ ExpectedAction::Multiple { expected_actions: vec![$($action),*] }\n+ };\n+ }\n+ \n+ macro_rules! scrolling_suggestions {\n+ ($($completion:expr),* $(,)?) => {\n+ ExpectedAction::ScrollingSuggestions { completions: vec![$($completion.to_string()),*] }\n+ };\n+ }\n+ \n+ macro_rules! db_suggestions {\n+ ($partial:expr, $prefix:expr) => {\n+ ExpectedAction::DbSuggestions { partial: $partial.to_string(), prefix: $prefix.to_string() }\n+ };\n+ }\n+\n+ // === NEW DECLARATIVE TESTS ===\n+\n+ #[test]\n+ fn test_declarative_https_tab_flow() {\n+ test_flow(\"HTTPS Tab Completion\", vec![\n+ (KeyAction::Type(\"https://reddit.com\".to_string()), \n+ ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n+ (KeyAction::Tab, \n+ multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"),\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_quick_subreddit_flow() {\n+ test_flow(\"Quick Subreddit Flow\", vec![\n+ (KeyAction::Type(\"r\".to_string()), \n+ ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n+ (KeyAction::Tab, \n+ ExpectedAction::MultipleAny), // r/ shows subreddit selection\n+ (KeyAction::Type(\"rust\".to_string()),\n+ ExpectedAction::ResolveSubreddit { subreddit: \"rust\".to_string(), prefix: \"r/rust\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_complete_journey() {\n+ test_flow(\"Complete User Journey\", vec![\n+ (KeyAction::Type(\"http\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"https://\".to_string() }),\n+ (KeyAction::Type(\"s://r\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"https://reddit.com\".to_string() }),\n+ (KeyAction::Tab,\n+ ExpectedAction::Suggestion { completion: \"https://reddit.com/\".to_string() }),\n+ (KeyAction::Type(\"/r/programming/top\".to_string()),\n+ ExpectedAction::RenderSubreddit { \n+ subreddit: \"programming\".to_string(), \n+ sort: Some(\"top\".to_string()) \n+ }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_user_profile() {\n+ test_flow(\"User Profile Navigation\", vec![\n+ (KeyAction::Type(\"reddit.com/u/spez\".to_string()),\n+ ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_error_handling() {\n+ test_flow(\"Error Handling\", vec![\n+ (KeyAction::Type(\"reddit.com/invalid/path\".to_string()),\n+ ExpectedAction::Error { error_type: \"InvalidPath\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_progressive_completion() {\n+ test_flow(\"Progressive Completion\", vec![\n+ (KeyAction::Type(\"r\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"r/\".to_string() }),\n+ (KeyAction::Type(\"e\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n+ (KeyAction::Type(\"d\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n+ (KeyAction::Type(\"dit\".to_string()),\n+ ExpectedAction::Suggestion { completion: \"reddit.com\".to_string() }),\n+ (KeyAction::Tab,\n+ ExpectedAction::Suggestion { completion: \"reddit.com/\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_declarative_subreddit_guide() {\n+ test_flow(\"Subreddit Guide\", vec![\n+ (KeyAction::Type(\"reddit.com/r/programming/\".to_string()),\n+ ExpectedAction::Guide { title_contains: \"What to sort\".to_string() }),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_clean_macro_example() {\n+ // This is what the tests can look like with macros!\n+ test_flow(\"Clean Macro Example\", flow![\n+ (type_text!(\"r\"), suggests!(\"r/\")),\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ (type_text!(\"rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_unified_subreddit_resolution_flow() {\n+ // This test demonstrates the new unified behavior:\n+ // - r/programming (exact) → tries exact match first\n+ // - r/pro (partial) → tries exact match, then suggestions + TAB completion\n+ \n+ test_flow(\"Unified Resolution: Exact Match\", flow![\n+ (type_text!(\"r/programming\"), resolves_subreddit!(\"programming\", \"r/programming\")),\n+ ]);\n+ \n+ test_flow(\"Unified Resolution: Partial Match\", flow![\n+ (type_text!(\"r/pro\"), resolves_subreddit!(\"pro\", \"r/pro\")),\n+ ]);\n+ \n+ // Both go through the same action type, but dispatcher handles them differently:\n+ // - If \"programming\" exists in DB → shows EntityView immediately\n+ // - If \"pro\" doesn't exist in DB → shows Multiple with:\n+ // 1. Suggestions (for TAB completion to best match) \n+ // 2. Selection (for clickable options including import)\n+ }\n+\n+ #[test]\n+ fn test_tab_completion_workflow_unified() {\n+ // This demonstrates the desired TAB completion behavior:\n+ // r/pro + TAB → r/programming (if \"programming\" is the best DB match)\n+ \n+ // Note: This test shows the PARSER behavior. The actual TAB completion \n+ // happens in the frontend when it receives the Multiple response containing\n+ // both Suggestions (for TAB) and Selection (for click options).\n+ \n+ let graph = build_reddit_graph();\n+ \n+ // 1. Parser generates unified action for partial input\n+ match graph.parse(\"r/pro\") {\n+ UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n+ assert_eq!(subreddit, \"pro\");\n+ assert_eq!(prefix, \"r/pro\");\n+ println!(\"✓ Parser correctly identifies 'r/pro' as subreddit resolution\");\n+ }\n+ _ => panic!(\"Expected ResolveAndDisplaySubreddit for 'r/pro'\"),\n+ }\n+ \n+ // 2. When dispatcher runs (in real app), it will return Multiple response with:\n+ // - Suggestions: { completion: \"r/programming\" } (for TAB)\n+ // - Selection: [ \"Import r/pro\", \"r/programming\", ... ] (for clicks)\n+ \n+ println!(\"✓ TAB completion workflow: r/pro → ResolveAndDisplaySubreddit → Multiple(Suggestions + Selection)\");\n+ }\n+\n+ #[test]\n+ fn test_ultra_clean_user_journey() {\n+ test_flow(\"Ultra Clean User Journey\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ (type_text!(\"ttps://r\"), suggests!(\"https://reddit.com\")), \n+ (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n+ (type_text!(\"/u/spez\"), ExpectedAction::RenderUser { username: \"spez\".to_string() }),\n+ ]);\n+ }\n+\n+ // === TESTS FROM parser.tdsl ===\n+\n+ #[test]\n+ fn test_tdsl_basic_reddit_progression() {\n+ // Tests from parser.tdsl: r -> r/, then re -> reddit.com with all intermediate steps\n+ test_flow(\"TDSL Basic Reddit Progression\", flow![\n+ (type_text!(\"r\"), suggests!(\"r/\")),\n+ (type_text!(\"e\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"d\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"d\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"i\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"t\"), suggests!(\"reddit.com\")),\n+ (type_text!(\".\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"c\"), suggests!(\"reddit.com\")),\n+ (type_text!(\"o\"), suggests!(\"reddit.com\")),\n+ (KeyAction::Tab, suggests!(\"reddit.com/\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_reddit_com_slash_infographic() {\n+ // reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)}\n+ test_flow(\"TDSL Reddit.com/ Infographic\", flow![\n+ (type_text!(\"reddit.com/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_selection() {\n+ // reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n+ test_flow(\"TDSL Subreddit Selection\", flow![\n+ (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_view() {\n+ // reddit.com/r/{sub} -> {resolve subreddit (exact match or suggestions)}\n+ test_flow(\"TDSL Subreddit View\", flow![\n+ (type_text!(\"reddit.com/r/programming\"), resolves_subreddit!(\"programming\", \"reddit.com/r/programming\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_slash_infographic() {\n+ // reddit.com/r/{sub}/ -> {show infographic or something}\n+ test_flow(\"TDSL Subreddit Slash Infographic\", flow![\n+ (type_text!(\"reddit.com/r/programming/\"), shows_guide!(\"What to sort\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_subreddit_comments() {\n+ // reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n+ test_flow(\"TDSL Subreddit Comments\", flow![\n+ (type_text!(\"reddit.com/r/programming/comments\"), renders_subreddit_comments!(\"programming\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_h_to_https() {\n+ // h->https:// (show supported domains)\n+ test_flow(\"TDSL H to HTTPS\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_composable_https_reddit() {\n+ // https://r->https://reddit.com/\n+ test_flow(\"TDSL Composable HTTPS Reddit\", flow![\n+ (type_text!(\"https://r\"), suggests!(\"https://reddit.com\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_composable_https_www() {\n+ // https://w->https://www.\n+ test_flow(\"TDSL Composable HTTPS WWW\", flow![\n+ (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_composable_https_www_reddit() {\n+ // https://www.r->https://www.reddit.com/\n+ test_flow(\"TDSL Composable HTTPS WWW Reddit\", flow![\n+ (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_full_composable_chain() {\n+ // Complete chain showing composability: h -> https:// -> https://www.reddit.com\n+ test_flow(\"TDSL Full Composable Chain Step 1\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Full Composable Chain Step 2\", flow![\n+ (type_text!(\"https://w\"), suggests!(\"https://www.\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Full Composable Chain Step 3\", flow![\n+ (type_text!(\"https://www.r\"), suggests!(\"https://www.reddit.com\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_progressive_reddit_paths() {\n+ // Test various reddit paths work as expected\n+ test_flow(\"TDSL Progressive Reddit Paths\", flow![\n+ (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test] \n+ fn test_tdsl_subreddit_sorting_options() {\n+ // Test that subreddit sorting options work as described\n+ test_flow(\"TDSL Subreddit Sorting\", flow![\n+ (type_text!(\"reddit.com/r/rust/hot\"), renders_subreddit!(\"rust\", \"hot\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Subreddit Top\", flow![\n+ (type_text!(\"reddit.com/r/rust/top\"), renders_subreddit!(\"rust\", \"top\")),\n+ ]);\n+ \n+ test_flow(\"TDSL Subreddit New\", flow![\n+ (type_text!(\"reddit.com/r/rust/new\"), renders_subreddit!(\"rust\", \"new\")),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_tdsl_all_reddit_prefixes() {\n+ // Test all the prefixes mentioned in parser.tdsl work\n+ // \"r\" now suggests \"r/\", all others suggest \"reddit.com\"\n+ let prefixes_to_reddit = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n+ \n+ // Test \"r\" separately since it now suggests \"r/\"\n+ test_flow(\"TDSL Prefix: r\", flow![\n+ (type_text!(\"r\"), suggests!(\"r/\")),\n+ ]);\n+ \n+ for prefix in prefixes_to_reddit {\n+ test_flow(&format!(\"TDSL Prefix: {}\", prefix), flow![\n+ (type_text!(prefix), suggests!(\"reddit.com\")),\n+ ]);\n+ }\n+ }\n+\n+ #[test]\n+ fn test_tdsl_protocol_combinations() {\n+ // Test various protocol combinations from parser.tdsl\n+ let test_cases = vec![\n+ (\"http://r\", \"http://reddit.com\"),\n+ (\"https://r\", \"https://reddit.com\"),\n+ (\"www.r\", \"www.reddit.com\"),\n+ (\"https://www.r\", \"https://www.reddit.com\"),\n+ (\"http://www.r\", \"http://www.reddit.com\"),\n+ ];\n+ \n+ for (input, expected) in test_cases {\n+ test_flow(&format!(\"TDSL Protocol: {}\", input), flow![\n+ (type_text!(input), suggests!(expected)),\n+ ]);\n+ }\n+ }\n+\n+ #[test]\n+ fn test_tdsl_edge_case_completions() {\n+ // Test edge cases mentioned in parser.tdsl\n+ test_flow(\"TDSL Reddit.com completion\", flow![\n+ (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n+ ]);\n+ \n+ // Test that typing full reddit.com suggests the slash\n+ test_flow(\"TDSL Full domain completion\", flow![\n+ (type_text!(\"reddit.com\"), suggests!(\"reddit.com/\")),\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_real_user_trace_2025_08_07_fixed() {\n+ // Based on actual WebSocket trace from 2025-08-07T01:35:46Z\n+ // This test shows the CORRECT behavior after fixing the protocol preservation bug\n+ test_flow(\"Real User Trace: Progressive Typing with Tab Completions (Fixed)\", flow![\n+ // User started typing \"h\"\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ \n+ // User continued to \"ht\" \n+ (type_text!(\"t\"), suggests!(\"https://\")),\n+ \n+ // User finished typing \"https://\" (trace shows full protocol)\n+ (type_text!(\"tps://\"), suggests!(\"https://\")),\n+ \n+ // User started typing \"r\" after protocol\n+ (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n+ \n+ // User continued typing \"re\"\n+ (type_text!(\"e\"), suggests!(\"https://reddit.com\")),\n+ \n+ // User typed out or completed \"https://reddit.com\"\n+ (type_text!(\"ddit.com\"), suggests!(\"https://reddit.com/\")),\n+ \n+ // User accepted completion to \"https://reddit.com/\"\n+ // NOW suggestions should preserve the https:// protocol\n+ (KeyAction::Tab, multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // This is the key fix!\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_protocol_preservation_bug_fix() {\n+ // This test specifically verifies the fix for the protocol preservation bug\n+ test_flow(\"Protocol Preservation: HTTPS Reddit Homepage\", flow![\n+ (type_text!(\"https://reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/u/\"), // Should preserve https://\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ \n+ // Test that subreddit selection preserves protocol\n+ test_flow(\"Protocol Preservation: HTTPS Subreddit Selection\", flow![\n+ (type_text!(\"https://reddit.com/r/\"), multiple![\n+ scrolling_suggestions!(\"https://reddit.com/r/programming\", \"https://reddit.com/r/askreddit\", \"https://reddit.com/r/aww\", \"https://reddit.com/r/rust\", \"https://reddit.com/r/webdev\"), // Should preserve https://\n+ db_suggestions!(\"https://reddit.com/r/\", \"https://reddit.com/r/\")\n+ ]),\n+ ]);\n+ \n+ // Test with different protocols\n+ test_flow(\"Protocol Preservation: HTTP\", flow![\n+ (type_text!(\"http://reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"http://reddit.com/r/\", \"http://reddit.com/u/\"), // Should preserve http://\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ \n+ test_flow(\"Protocol Preservation: WWW\", flow![\n+ (type_text!(\"www.reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"www.reddit.com/r/\", \"www.reddit.com/u/\"), // Should preserve www.\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ \n+ // Test that plain reddit.com still works\n+ test_flow(\"Protocol Preservation: Plain Domain\", flow![\n+ (type_text!(\"reddit.com/\"), multiple![\n+ scrolling_suggestions!(\"reddit.com/r/\", \"reddit.com/u/\"), // No protocol prefix\n+ shows_guide!(\"Welcome to Sorter\")\n+ ]),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_user_navigation_pattern() {\n+ // Models how users actually navigate: type → tab → click suggestion → end up at destination\n+ // This captures the \"jump\" from https://reddit.com/ to reddit.com/r/ seen in the trace\n+ test_flow(\"User Navigation: Protocol to Domain\", flow![\n+ // User types and gets to homepage\n+ (type_text!(\"https://reddit.com/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ \n+ // Then they navigate (perhaps clicking a suggestion) to subreddit selection\n+ test_flow(\"User Navigation: Click to Subreddit\", flow![\n+ // This is where they ended up - the suggestion in the guide probably said \"reddit.com/r/\"\n+ (type_text!(\"reddit.com/r/\"), ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ #[test]\n+ fn test_progressive_typing_pattern() {\n+ // Based on the trace pattern - users often type character by character\n+ // This tests the exact sequence of suggestions they would see\n+ test_flow(\"Progressive Typing Pattern\", flow![\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ (type_text!(\"t\"), suggests!(\"https://\")), // Still suggests https://\n+ (type_text!(\"t\"), suggests!(\"https://\")), // ht -> htt, still suggests https://\n+ (type_text!(\"p\"), suggests!(\"https://\")), // http should still suggest https://\n+ (type_text!(\"s\"), suggests!(\"https://\")), // https should suggest https://\n+ (type_text!(\"://\"), suggests!(\"https://\")), // Even complete protocol still suggests itself\n+ ]);\n+ }\n+\n+ #[test] \n+ fn test_tab_completion_workflow() {\n+ // Test what happens when user uses tab completions strategically\n+ test_flow(\"Strategic Tab Completion Workflow\", flow![\n+ // Start typing, get suggestion\n+ (type_text!(\"h\"), suggests!(\"https://\")),\n+ \n+ // Accept suggestion with tab - this should move us to \"https://\"\n+ (KeyAction::Tab, suggests!(\"https://\")), // After tab, we're at \"https://\" which still suggests itself\n+ \n+ // Start typing reddit\n+ (type_text!(\"r\"), suggests!(\"https://reddit.com\")),\n+ \n+ // Accept reddit suggestion \n+ (KeyAction::Tab, suggests!(\"https://reddit.com/\")),\n+ \n+ // Accept final suggestion to get to homepage\n+ (KeyAction::Tab, ExpectedAction::MultipleAny),\n+ ]);\n+ }\n+\n+ // Keep the original tests as well\n+ #[test]\n+ fn test_reddit_prefix_autocomplete() {\n+ let graph = build_reddit_graph();\n+ \n+ // \"r\" now suggests \"r/\", others suggest \"reddit.com\"\n+ match graph.parse(\"r\") {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(data.suggestion.as_ref().unwrap().completion, \"r/\");\n+ println!(\"✓ 'r' → r/\");\n+ }\n+ _ => panic!(\"Expected suggestion for 'r'\"),\n+ }\n+ \n+ // Test other prefixes that should suggest \"reddit.com\"\n+ let prefixes = vec![\"re\", \"red\", \"redd\", \"reddi\", \"reddit\", \"reddit.\", \"reddit.c\", \"reddit.co\"];\n+ \n+ for prefix in prefixes {\n+ match graph.parse(prefix) {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(data.suggestion.as_ref().unwrap().completion, \"reddit.com\");\n+ println!(\"✓ '{}' → reddit.com\", prefix);\n+ }\n+ _ => panic!(\"Expected suggestion for '{}'\", prefix),\n+ }\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_protocol_composition() {\n+ let graph = build_reddit_graph();\n+ \n+ // Test protocol + reddit compositions\n+ let tests = vec![\n+ (\"https://r\", \"https://reddit.com\"),\n+ (\"https://re\", \"https://reddit.com\"),\n+ (\"https://reddit\", \"https://reddit.com\"),\n+ (\"https://www.r\", \"https://www.reddit.com\"),\n+ (\"https://www.reddit\", \"https://www.reddit.com\"),\n+ (\"http://r\", \"http://reddit.com\"),\n+ (\"www.r\", \"www.reddit.com\"),\n+ ];\n+ \n+ for (input, expected) in tests {\n+ match graph.parse(input) {\n+ UIAction::ShowSuggestions(data) => {\n+ assert_eq!(data.suggestion.as_ref().unwrap().completion, expected);\n+ println!(\"✓ '{}' → {}\", input, expected);\n+ }\n+ _ => panic!(\"Expected suggestion for '{}'\", input),\n+ }\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_alias_and_full_paths() {\n+ let graph = build_reddit_graph();\n+ \n+ // reddit.com/ should show guide, r/ should show subreddit selection\n+ match graph.parse(\"reddit.com/\") {\n+ UIAction::ShowMultiple { actions } => {\n+ let has_guide = actions.iter()\n+ .any(|a| matches!(a, UIAction::ShowStaticGuide { .. }));\n+ assert!(has_guide, \"Path 'reddit.com/' should show guide\");\n+ println!(\"✓ 'reddit.com/' shows Reddit root guide\");\n+ }\n+ _ => panic!(\"Expected Multiple action for 'reddit.com/'\"),\n+ }\n+ \n+ match graph.parse(\"r/\") {\n+ UIAction::ShowMultiple { actions } => {\n+ let has_scrolling_suggestions = actions.iter()\n+ .any(|a| matches!(a, UIAction::ShowScrollingSuggestions { .. }));\n+ let has_db_suggestions = actions.iter()\n+ .any(|a| matches!(a, UIAction::SuggestSubredditsFromDb { .. }));\n+ assert!(has_scrolling_suggestions && has_db_suggestions, \n+ \"Path 'r/' should show scrolling suggestions and DB suggestions\");\n+ println!(\"✓ 'r/' shows subreddit selection\");\n+ }\n+ _ => panic!(\"Expected Multiple action for 'r/'\"),\n+ }\n+ }\n+ \n+ #[test]\n+ fn test_deep_navigation() {\n+ let graph = build_reddit_graph();\n+ \n+ // Test navigation to subreddit - now uses ResolveAndDisplaySubreddit\n+ match graph.parse(\"reddit.com/r/rust\") {\n+ UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n+ assert_eq!(subreddit, \"rust\");\n+ assert_eq!(prefix, \"reddit.com/r/rust\");\n+ println!(\"✓ reddit.com/r/rust recognized\");\n+ }\n+ _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit\"),\n+ }\n+ \n+ // Test with alias (r/ goes directly to subreddit, no double r/)\n+ match graph.parse(\"r/programming\") {\n+ UIAction::ResolveAndDisplaySubreddit { subreddit, prefix } => {\n+ assert_eq!(subreddit, \"programming\");\n+ assert_eq!(prefix, \"r/programming\");\n+ println!(\"✓ r/programming (alias) recognized\");\n+ }\n+ _ => panic!(\"Expected ResolveAndDisplaySubreddit for subreddit via alias\"),\n+ }\n+ }\n+}\ndiff --git a/parser.tdsl b/parser.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..03ba9563ea8c73b7411cd1aa42dda9c7c313cd39\n--- /dev/null\n+++ b/parser.tdsl\n@@ -0,0 +1,32 @@\n+The parser is a series of edges/transitions, for example:\n+r -> reddit.com/ \n+where -> means that all of\n+ r->reddit.com/\n+ re->reddit.com/\n+ red->reddit.com/\n+ redd->reddit.com/\n+ reddi->reddit.com/\n+ reddit->reddit.com/\n+ reddit.->reddit.com/\n+ reddit.c->reddit.com/\n+ reddit.co->reddit.com/\n+ reddit.com->reddt.com/\n+ are defined as autocomplete suggestions\n+\n+re -> reddit.com/\n+reddit.com/ -> {show infographic explaining that u (sort user posts) and r (sort subreddit posts)} -> reddit.com/r/\n+reddit.com/r/ -> reddit.com/r/{randomly chose sub from list}\n+reddit.com/r/{sub} -> {show subrdedit view} -> reddit.com/r/{sub}/\n+reddit.com/r/{sub}/ -> {show infographic or something} -> reddit.com/r/{sub}/comments/{randomly chose comment from sql}\n+\n+\n+h->https:// (show supported domains)\n+\n+// the edges are composable such that this is possible, while reusing the above reddit code.\n+https://r->https://reddit.com/\n+https://w->https://www.\n+https://www.r->https://www.reddit.com/\n+\n+\n+// and i also want to have commands after you press space, like\n+reddit.com/r/programming !vote(reddit.comment{t3_123, t4_4444})% \ndiff --git a/ranking.rs b/ranking.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..34241cf6bf13117b3ad3262761eb7f7b48b54d80\n--- /dev/null\n+++ b/ranking.rs\n@@ -0,0 +1,393 @@\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ cat server/src/ranking.rs \n+use std::collections::{HashMap, HashSet};\n+\n+use crate::path_types::ItemId;\n+use crate::reducer::GroupState;\n+\n+#[derive(Debug, Clone)]\n+pub struct RankedItem {\n+ pub item: ItemId,\n+ pub score: f64,\n+}\n+\n+/// Compute connected components over the voted-pairs graph (treated as undirected).\n+///\n+/// Returns:\n+/// - `components`: each component is a sorted list of node indices, excluding isolates.\n+/// - `isolates`: sorted list of node indices with degree 0 (no voted pairs).\n+pub fn connected_components_from_voted_pairs(\n+ n: usize,\n+ voted_pairs: impl Iterator,\n+) -> (Vec>, Vec) {\n+ let mut adj: Vec> = vec![Vec::new(); n];\n+ for (a, b) in voted_pairs {\n+ if a >= n || b >= n || a == b {\n+ continue;\n+ }\n+ adj[a].push(b);\n+ adj[b].push(a);\n+ }\n+\n+ let mut isolates: Vec = (0..n).filter(|&i| adj[i].is_empty()).collect();\n+ isolates.sort();\n+\n+ let mut seen = vec![false; n];\n+ for &i in &isolates {\n+ seen[i] = true;\n+ }\n+\n+ let mut comps: Vec> = Vec::new();\n+ for i in 0..n {\n+ if seen[i] {\n+ continue;\n+ }\n+ let mut stack = vec![i];\n+ seen[i] = true;\n+ let mut comp: Vec = Vec::new();\n+ while let Some(x) = stack.pop() {\n+ comp.push(x);\n+ for &y in &adj[x] {\n+ if !seen[y] {\n+ seen[y] = true;\n+ stack.push(y);\n+ }\n+ }\n+ }\n+ comp.sort();\n+ comps.push(comp);\n+ }\n+\n+ (comps, isolates)\n+}\n+\n+/// Compute rank centrality scores for a GroupState.\n+/// This matches the approach in the earlier standalone prototype but avoids dependencies by doing\n+/// an O(E) multiply per iteration.\n+pub fn compute_group_ranking(group: &mut GroupState, max_iters: usize, tol: f64) {\n+ if !group.dirty && !group.cached_scores.is_empty() {\n+ return;\n+ }\n+\n+ let n = group.idx_to_item.len();\n+ if n == 0 {\n+ group.cached_scores = vec![];\n+ group.dirty = false;\n+ return;\n+ }\n+ if n == 1 {\n+ group.cached_scores = vec![1.0];\n+ group.dirty = false;\n+ return;\n+ }\n+\n+ let scores = compute_scores_from_edges(\n+ n,\n+ group.edges.iter().map(|(&k, &w)| (k, w)),\n+ max_iters,\n+ tol,\n+ );\n+ group.cached_scores = scores;\n+ group.dirty = false;\n+}\n+\n+pub fn ranked_items(group: &mut GroupState, max_iters: usize, tol: f64) -> Vec {\n+ compute_group_ranking(group, max_iters, tol);\n+ let mut items: Vec = group\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, item)| RankedItem {\n+ item: item.clone(),\n+ score: *group.cached_scores.get(i).unwrap_or(&0.0),\n+ })\n+ .collect();\n+\n+ items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+ items\n+}\n+\n+fn compute_scores_from_edges(n: usize, edges: impl Iterator, max_iters: usize, tol: f64) -> Vec {\n+ if n == 0 {\n+ return vec![];\n+ }\n+ if n == 1 {\n+ return vec![1.0];\n+ }\n+\n+ // Collect raw edges into a map for pairwise normalization.\n+ let mut raw: HashMap<(usize, usize), f64> = HashMap::new();\n+ for ((src, dst), w) in edges {\n+ if src >= n || dst >= n || w <= 0.0 {\n+ continue;\n+ }\n+ *raw.entry((src, dst)).or_insert(0.0) += w;\n+ }\n+\n+ // Pairwise normalization: a_ij = A_ij / (A_ij + A_ji).\n+ // This ensures repeated votes on the same pair don't inflate influence\n+ // beyond what the ratio implies.\n+ let keys: Vec<(usize, usize)> = raw.keys().copied().collect();\n+ let mut normalized: HashMap<(usize, usize), f64> = HashMap::new();\n+ for (i, j) in keys {\n+ if normalized.contains_key(&(i, j)) {\n+ continue;\n+ }\n+ let w_ij = *raw.get(&(i, j)).unwrap_or(&0.0);\n+ let w_ji = *raw.get(&(j, i)).unwrap_or(&0.0);\n+ let total = w_ij + w_ji;\n+ if total <= 0.0 {\n+ continue;\n+ }\n+ normalized.insert((i, j), w_ij / total);\n+ if w_ji > 0.0 {\n+ normalized.insert((j, i), w_ji / total);\n+ }\n+ }\n+\n+ // Rank Centrality (Negahban, Oh, Shah 2012, §3.1):\n+ // P_ij = (1/d_max) * A_ij for i ≠ j compared\n+ // P_ii = 1 - (1/d_max) * Σ_k A_ik\n+ // where d_i is the *degree* (number of distinct neighbors compared) and\n+ // d_max = max_i d_i. Using the unweighted degree — not the sum of\n+ // pairwise-normalized weights — is what guarantees aperiodicity: it\n+ // forces P_ii > 0 for every non-maximum-degree node, and for max-degree\n+ // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+ // loss). Without this, regular comparison graphs (e.g. a pure star at\n+ // ratio 2:1) produce a bipartite chain that oscillates instead of\n+ // converging — see issue #146.\n+ let mut out_edges: Vec> = vec![Vec::new(); n];\n+ let mut neighbors: Vec> = vec![HashSet::new(); n];\n+\n+ for ((src, dst), w) in &normalized {\n+ out_edges[*src].push((*dst, *w));\n+ neighbors[*src].insert(*dst);\n+ neighbors[*dst].insert(*src);\n+ }\n+\n+ let weight_sum: Vec = out_edges\n+ .iter()\n+ .map(|es| es.iter().map(|(_, w)| *w).sum())\n+ .collect();\n+ let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+ if d_max == 0 {\n+ return vec![1.0 / n as f64; n];\n+ }\n+ let d_max_f = d_max as f64;\n+\n+ let mut scores = vec![1.0 / n as f64; n];\n+ let mut next = vec![0.0f64; n];\n+\n+ for _ in 0..max_iters {\n+ next.fill(0.0);\n+ for i in 0..n {\n+ let stay_prob = (d_max_f - weight_sum[i]) / d_max_f;\n+ next[i] += scores[i] * stay_prob;\n+\n+ if out_edges[i].is_empty() {\n+ continue;\n+ }\n+ for &(dst, w) in &out_edges[i] {\n+ next[dst] += scores[i] * (w / d_max_f);\n+ }\n+ }\n+\n+ let diff: f64 = scores\n+ .iter()\n+ .zip(next.iter())\n+ .map(|(a, b)| (a - b).abs())\n+ .sum();\n+\n+ scores.clone_from_slice(&next);\n+ if diff < tol {\n+ break;\n+ }\n+ }\n+\n+ let sum: f64 = scores.iter().sum();\n+ if sum.is_finite() && sum > 0.0 {\n+ for s in &mut scores {\n+ *s /= sum;\n+ }\n+ }\n+ scores\n+}\n+\n+/// Rank-centrality within a subset of items (an induced subgraph), using the group's aggregated edges.\n+///\n+/// `idxs` are indices into `group.idx_to_item`. The returned items use the original item names.\n+pub fn ranked_items_subset(group: &GroupState, idxs: &[usize], max_iters: usize, tol: f64) -> Vec {\n+ if idxs.is_empty() {\n+ return vec![];\n+ }\n+\n+ // Map original idx -> compact idx [0..m)\n+ let mut map: HashMap = HashMap::with_capacity(idxs.len());\n+ for (j, &i) in idxs.iter().enumerate() {\n+ map.insert(i, j);\n+ }\n+\n+ let edges_iter = group.edges.iter().filter_map(|(&(src, dst), &w)| {\n+ let s = *map.get(&src)?;\n+ let d = *map.get(&dst)?;\n+ Some(((s, d), w))\n+ });\n+\n+ let scores = compute_scores_from_edges(idxs.len(), edges_iter, max_iters, tol);\n+\n+ // Filter out entries where idx_to_item doesn't have the slot (shouldn't happen, but be safe).\n+ let mut items: Vec = idxs\n+ .iter()\n+ .enumerate()\n+ .filter_map(|(j, &orig)| {\n+ let item = group.idx_to_item.get(orig)?.clone();\n+ Some(RankedItem { item, score: *scores.get(j).unwrap_or(&0.0) })\n+ })\n+ .collect();\n+\n+ items.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));\n+ items\n+}\n+\n+pub fn group_summary_scores(\n+ group: &mut GroupState,\n+ max_iters: usize,\n+ tol: f64,\n+) -> HashMap {\n+ ranked_items(group, max_iters, tol)\n+ .into_iter()\n+ .map(|r| (r.item, r.score))\n+ .collect()\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+ use crate::reducer::VoteData;\n+\n+ fn mk_group() -> GroupState {\n+ GroupState::new()\n+ }\n+\n+ fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {\n+ use crate::path_types::ItemId;\n+ VoteData {\n+ ts,\n+ a: ItemId::parse(a).unwrap(),\n+ b: ItemId::parse(b).unwrap(),\n+ ratio_left: l,\n+ ratio_right: r,\n+ body: \"because\".to_string(),\n+ principal: \"test\".to_string(),\n+ delegate: Some(\"00000000-0000-0000-0000-000000000000:test:local/test\".to_string()),\n+ thread_tag: \"untagged\".to_string(),\n+ }\n+ }\n+\n+ /// Regression for issue #146: pure forward star at default `>` ratio (2:1).\n+ /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+ /// chain was bipartite; power iteration oscillated and returned the\n+ /// uniform initial distribution after an even number of steps. Using the\n+ /// paper's degree-based d_max gives every node a positive self-loop and\n+ /// the chain converges to the correct stationary distribution.\n+ #[test]\n+ fn star_topology_winner_at_top_via_subset() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+ let mut items: Vec<(usize, String)> = g\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, it)| (i, it.as_str().to_string()))\n+ .collect();\n+ items.sort_by(|a, b| a.1.cmp(&b.1));\n+ let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+ let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+ for r in &ranked {\n+ eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+ }\n+ assert_eq!(\n+ ranked[0].item.as_str(),\n+ \"https://slug.social/zebra\",\n+ \"zebra won both votes and should rank #1\"\n+ );\n+ }\n+\n+ #[test]\n+ fn group_ranking_cache_dirty_flow() {\n+ let mut g = mk_group();\n+ assert!(g.dirty);\n+\n+ g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+ assert!(g.dirty);\n+ assert!(g.cached_scores.is_empty());\n+\n+ compute_group_ranking(&mut g, 10000, 1e-8);\n+ assert!(!g.dirty);\n+ assert_eq!(g.cached_scores.len(), g.idx_to_item.len());\n+\n+ // Recomputing when not dirty should be a no-op.\n+ let before = g.cached_scores.clone();\n+ compute_group_ranking(&mut g, 10000, 1e-8);\n+ assert_eq!(before, g.cached_scores);\n+ }\n+\n+ #[test]\n+ fn connected_components_split_disconnected_pairs() {\n+ let mut g = mk_group();\n+ // Two disconnected edges: (a,b) and (c,d)\n+ g.apply_vote(vote(1, \"a\", \"b\", 3, 1));\n+ g.apply_vote(vote(2, \"c\", \"d\", 3, 1));\n+\n+ let n = g.idx_to_item.len();\n+ let (mut comps, isolates) =\n+ connected_components_from_voted_pairs(n, g.voted_pairs.iter().copied());\n+ assert!(isolates.is_empty());\n+ // Order-independent: sort components by their item names for stable assert.\n+ comps.sort_by_key(|c| {\n+ c.iter()\n+ .map(|&i| g.idx_to_item[i].clone())\n+ .collect::>()\n+ });\n+ assert_eq!(comps.len(), 2);\n+ let comp0 = comps[0]\n+ .iter()\n+ .map(|&i| g.idx_to_item[i].as_str())\n+ .collect::>();\n+ let comp1 = comps[1]\n+ .iter()\n+ .map(|&i| g.idx_to_item[i].as_str())\n+ .collect::>();\n+ assert_eq!(comp0, vec![\"https://slug.social/a\", \"https://slug.social/b\"]);\n+ assert_eq!(comp1, vec![\"https://slug.social/c\", \"https://slug.social/d\"]);\n+ }\n+\n+ #[test]\n+ fn subset_ranking_ranks_within_component_only() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"a\", \"b\", 3, 1)); // a > b\n+ g.apply_vote(vote(2, \"c\", \"d\", 1, 4)); // d > c\n+\n+ let (comps, _) =\n+ connected_components_from_voted_pairs(g.idx_to_item.len(), g.voted_pairs.iter().copied());\n+ assert_eq!(comps.len(), 2);\n+\n+ // Rank each component and ensure winner is first within that component.\n+ for comp in comps {\n+ let ranked = ranked_items_subset(&g, &comp, 10000, 1e-8);\n+ assert_eq!(ranked.len(), 2);\n+ let names = ranked.iter().map(|r| r.item.as_str()).collect::>();\n+ if names.contains(&\"https://slug.social/a\") {\n+ assert_eq!(names[0], \"https://slug.social/a\");\n+ } else {\n+ assert_eq!(names[0], \"https://slug.social/d\");\n+ }\n+ }\n+ }\n+}\n+\n+\n+tommy@Tommys-Laptop:~/programming/slug-star/slug|main ⇒ \n+\ndiff --git a/reducer.rs b/reducer.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..1ba8030feaf783b066b2e0bba4d2d05659c992f4\n--- /dev/null\n+++ b/reducer.rs\n@@ -0,0 +1,845 @@\n+use std::collections::{HashMap, HashSet, VecDeque};\n+\n+use serde::{Deserialize, Serialize};\n+\n+use crate::canonical_path::canonicalize_tag;\n+use crate::dsl;\n+use crate::events::{Event, Ingest, ThreadCapability};\n+use crate::path_types::ItemId;\n+\n+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n+pub enum ScopeId {\n+ Public,\n+ Room(String),\n+}\n+\n+/// Wire `room` field → content scope (`\"public\"` → [`ScopeId::Public`]).\n+pub fn scope_from_room_wire(room: &str) -> ScopeId {\n+ let r = room.trim();\n+ if r.is_empty() || r == \"public\" {\n+ ScopeId::Public\n+ } else {\n+ ScopeId::Room(r.to_string())\n+ }\n+}\n+\n+/// Parsed vote data (internal representation).\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct VoteData {\n+ pub ts: i64,\n+ pub a: ItemId,\n+ pub b: ItemId,\n+ pub ratio_left: i32,\n+ pub ratio_right: i32,\n+ pub body: String,\n+ pub principal: String,\n+ pub delegate: Option,\n+ /// Forum channel where this vote was cast (tag only, not room id).\n+ pub thread_tag: String,\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct GroupState {\n+ pub item_to_idx: HashMap,\n+ pub idx_to_item: Vec,\n+\n+ /// Aggregated directed edge weights: (src_idx, dst_idx) -> weight.\n+ pub edges: HashMap<(usize, usize), f64>,\n+\n+ /// Unordered pairs that have at least one vote recorded between them (i,\n+ pub dirty: bool,\n+ pub cached_scores: Vec,\n+ pub recent_votes: VecDeque,\n+}\n+\n+impl Default for GroupState {\n+ fn default() -> Self {\n+ Self::new()\n+ }\n+}\n+\n+impl GroupState {\n+ pub fn new() -> Self {\n+ Self {\n+ item_to_idx: HashMap::new(),\n+ idx_to_item: Vec::new(),\n+ edges: HashMap::new(),\n+ voted_pairs: HashSet::new(),\n+ dirty: true,\n+ cached_scores: Vec::new(),\n+ recent_votes: VecDeque::with_capacity(200),\n+ }\n+ }\n+\n+ fn ensure_item(&mut self, item: &ItemId) -> usize {\n+ if let Some(&idx) = self.item_to_idx.get(item) {\n+ return idx;\n+ }\n+ let idx = self.idx_to_item.len();\n+ self.idx_to_item.push(item.clone());\n+ self.item_to_idx.insert(item.clone(), idx);\n+ self.dirty = true;\n+ idx\n+ }\n+\n+ /// Public test helper: insert an item into the group without a vote (for unit tests).\n+ pub fn ensure_item_pub(&mut self, item: &str) -> usize {\n+ if let Some(canon) = ItemId::parse(item) {\n+ self.ensure_item(&canon)\n+ } else {\n+ // Fallback: treat as raw storage string\n+ let canon = ItemId::opaque(item.to_string());\n+ self.ensure_item(&canon)\n+ }\n+ }\n+\n+ fn add_edge_weight(&mut self, src: usize, dst: usize, w: f64) {\n+ if w <= 0.0 {\n+ return;\n+ }\n+ *self.edges.entry((src, dst)).or_insert(0.0) += w;\n+ self.dirty = true;\n+ }\n+\n+ pub fn apply_vote(&mut self, mut vote: VoteData) {\n+ vote.a = ItemId::parse(vote.a.as_str()).unwrap_or_else(|| vote.a.clone());\n+ vote.b = ItemId::parse(vote.b.as_str()).unwrap_or_else(|| vote.b.clone());\n+ vote.thread_tag = canonicalize_tag(&vote.thread_tag);\n+ if vote.ratio_left < 0 {\n+ vote.ratio_left = 0;\n+ }\n+ if vote.ratio_right < 0 {\n+ vote.ratio_right = 0;\n+ }\n+ let a_idx = self.ensure_item(&vote.a);\n+ let b_idx = self.ensure_item(&vote.b);\n+\n+ let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) };\n+ self.voted_pairs.insert((i, j));\n+\n+ let w_a = vote.ratio_left as f64;\n+ let w_b = vote.ratio_right as f64;\n+\n+ self.add_edge_weight(b_idx, a_idx, w_a);\n+ self.add_edge_weight(a_idx, b_idx, w_b);\n+\n+ self.recent_votes.push_front(vote);\n+ while self.recent_votes.len() > 200 {\n+ self.recent_votes.pop_back();\n+ }\n+ }\n+}\n+\n+/// Compact rank-history entry stored per item in the ledger.\n+/// `caused_by` is resolved lazily at query time from `ingests_by_id`.\n+#[derive(Debug, Clone)]\n+pub struct RankHistoryEntry {\n+ pub ts: i64,\n+ pub scope_rank: usize,\n+ pub scope_rank_delta: i32,\n+ pub scope_total: usize,\n+ pub global_rank: usize,\n+ pub global_rank_delta: i32,\n+ pub global_total: usize,\n+ pub score: f64,\n+ pub thread: String,\n+ pub post_id: String,\n+}\n+\n+/// Invite link materialized from log events [`crate::events::InviteMinted`] /\n+/// [`crate::events::InviteRedeemed`] when those are replayed.\n+///\n+/// **`RoomMintInvite` today** stores tokens only in [`crate::state::AppState::invites`] (RAM);\n+/// they are not appended to the JSONL log, so they disappear on restart. This struct is for\n+/// replay and any future persisted-mint path, not for the current ephemeral RPC mint.\n+#[derive(Debug, Clone)]\n+pub struct ActiveInviteState {\n+ pub room_id: String,\n+ pub capabilities: HashSet,\n+ pub inviter: String,\n+ pub uses_remaining: u32,\n+ pub expires_ts_ms: Option,\n+}\n+\n+#[derive(Clone, Debug)]\n+pub enum RoomTimelineKind {\n+ RoomCreated {\n+ owner: String,\n+ slug: String,\n+ },\n+ RoomDeleted {\n+ deleted_by: String,\n+ },\n+ GrantAdded {\n+ username: String,\n+ granted_by: String,\n+ capabilities: Vec,\n+ },\n+ GrantRevoked {\n+ username: String,\n+ revoked_by: String,\n+ capabilities: Vec,\n+ },\n+}\n+\n+#[derive(Clone, Debug)]\n+pub struct RoomTimelineEntry {\n+ pub ts: i64,\n+ pub kind: RoomTimelineKind,\n+}\n+\n+#[derive(Debug, Clone)]\n+#[derive(Default)]\n+pub struct ForumThreadState {\n+ pub last_activity_ts: i64,\n+ /// Username of the most recent person who bumped this thread.\n+ pub last_actor: String,\n+}\n+\n+\n+#[derive(Debug, Clone, Default)]\n+pub struct ContentState {\n+ pub ranking_group: GroupState,\n+ pub items: HashSet,\n+ pub item_bodies: HashMap,\n+ /// Parent [`ItemId`] -> direct children.\n+ pub item_children: HashMap>,\n+ /// Per-item vote history (most recent first).\n+ pub item_votes: HashMap>,\n+ /// Per-item ingest references (most recent first).\n+ pub item_snippets: HashMap>,\n+ /// Item path -> threads that mention or vote on this item.\n+ pub item_threads: HashMap>,\n+ /// Per-item rank history, oldest first.\n+ pub rank_history: HashMap>,\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct ReducerState {\n+ pub content: HashMap,\n+\n+ /// (provider, provider_id) -> username\n+ pub users_by_provider: HashMap<(String, String), String>,\n+ /// token_id -> (username, salt, token_hash)\n+ pub tokens_by_id: HashMap,\n+ pub agent_bindings: HashMap,\n+\n+\n+ pub ingests_by_id: HashMap,\n+ /// (scope, thread_tag) → ingest ids, newest first.\n+ pub ingests_by_scope_thread: HashMap<(ScopeId, String), VecDeque>,\n+ /// Private room ids (`shortid/slug`) known from [`RoomCreated`].\n+ pub rooms: HashSet,\n+ /// (scope, thread_tag) → last activity.\n+ pub forum_threads: HashMap<(ScopeId, String), ForumThreadState>,\n+ pub actor_last_post_ts: HashMap,\n+\n+ /// All ingest IDs in chronological order (oldest first). Used by the feed endpoint.\n+ pub ingests_ordered: Vec,\n+ /// Username → ingest ids in post order (oldest first), for profile pages.\n+ pub posts_by_actor: HashMap>,\n+ /// Ingest ids removed by author redaction (content and thread body omitted; tombstone in UI).\n+ pub redacted_posts: HashSet,\n+ /// Redaction event timestamp (ms) per post id.\n+ pub post_redact_ts: HashMap,\n+ /// room_id → username → capabilities\n+ pub grants: HashMap>>,\n+ /// room_id → chronological room admin lines (for thread UI).\n+ pub room_timeline: HashMap>,\n+ /// Invite token → active invite (absent when fully consumed or never minted).\n+ pub invites: HashMap,\n+}\n+\n+impl ReducerState {\n+ pub fn content_for_scope(&self, scope: &ScopeId) -> Option<&ContentState> {\n+ self.content.get(scope)\n+ }\n+\n+ /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`).\n+ pub fn try_thread_post_index_chronological(\n+ &self,\n+ scope: &ScopeId,\n+ thread_tag: &str,\n+ post_id: &str,\n+ ) -> Option {\n+ let tag = canonicalize_tag(thread_tag);\n+ self.ingests_by_scope_thread\n+ .get(&(scope.clone(), tag))\n+ .and_then(|q| q.iter().rev().position(|pid| pid == post_id))\n+ }\n+\n+ pub fn thread_post_index_chronological(\n+ &self,\n+ scope: &ScopeId,\n+ thread_tag: &str,\n+ post_id: &str,\n+ ) -> usize {\n+ self.try_thread_post_index_chronological(scope, thread_tag, post_id)\n+ .expect(\n+ \"post_id must appear in ingests_by_scope_thread for this scope and thread\",\n+ )\n+ }\n+\n+ /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest.\n+ pub fn posts_by_actor_ids(&self, actor: &str) -> Vec {\n+ self.posts_by_actor\n+ .get(actor)\n+ .map(|q| q.iter().cloned().collect())\n+ .unwrap_or_default()\n+ }\n+\n+ /// Same order as [`Self::posts_by_actor_ids`], omitting ingests in scopes the viewer cannot see.\n+ pub fn visible_posts_for_actor(&self, actor: &str, viewer: Option<&str>) -> Vec {\n+ self.posts_by_actor_ids(actor)\n+ .into_iter()\n+ .filter(|id| {\n+ !self.redacted_posts.contains(id)\n+ && self.ingests_by_id.get(id).is_some_and(|ing| {\n+ let scope = scope_from_room_wire(&ing.room_id);\n+ match &scope {\n+ ScopeId::Public => true,\n+ ScopeId::Room(rid) => {\n+ viewer.is_some_and(|u| self.user_has_cap(rid, u, ThreadCapability::View))\n+ }\n+ }\n+ })\n+ })\n+ .collect()\n+ }\n+\n+ pub fn user_has_cap(&self, room_id: &str, username: &str, cap: ThreadCapability) -> bool {\n+ self.grants\n+ .get(room_id)\n+ .and_then(|t| t.get(username))\n+ .map(|caps| caps.contains(&cap))\n+ .unwrap_or(false)\n+ }\n+\n+ /// Invite link is present, not expired, and has uses left.\n+ pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {\n+ let inv = self.invites.get(token)?;\n+ if inv.uses_remaining == 0 {\n+ return None;\n+ }\n+ if let Some(exp) = inv.expires_ts_ms {\n+ if now_ms > exp {\n+ return None;\n+ }\n+ }\n+ Some(inv)\n+ }\n+\n+ pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {\n+ self.content.entry(scope).or_default()\n+ }\n+\n+ pub fn public(&self) -> &ContentState {\n+ self.content.get(&ScopeId::Public).expect(\"public scope missing\")\n+ }\n+\n+ /// Register parent→child edges for the full ancestor chain.\n+ /// For `a/b/c/d` this creates: `a/b/c→d`, `a/b→a/b/c`, `a→a/b`, `\"\"→a`.\n+ /// Stops early when an intermediate is already registered (its ancestors must be too).\n+ /// @e2bdefa9-a6fa-4725-b0a2-c0b09d95bb20:claudecode:anthropic/claude-opus-4\n+ fn add_child_edge(content: &mut ContentState, item: &ItemId) {\n+ let mut child = item.clone();\n+ loop {\n+ let Some(parent) = child.parent() else { break };\n+ let is_new = content\n+ .item_children\n+ .entry(parent.clone())\n+ .or_default()\n+ .insert(child);\n+ if !is_new { break; }\n+ child = parent;\n+ }\n+ }\n+\n+ /// Resolve an item path as a first-class [`ItemId`].\n+ fn normalize_item(item: &str) -> Option {\n+ ItemId::parse(item)\n+ }\n+\n+ /// 1-indexed rank of `item` within its connected component in the parent scope.\n+ /// 0 if the item has no votes connecting it to siblings (unranked).\n+ fn scope_rank_of(\n+ group: &GroupState,\n+ item: &ItemId,\n+ item_children: &HashMap>,\n+ ) -> usize {\n+ let scope = match item.parent() {\n+ Some(p) => p,\n+ None => return 0,\n+ };\n+ let children = match item_children.get(&scope) {\n+ None => return 0,\n+ Some(c) => c,\n+ };\n+ let &item_global_idx = match group.item_to_idx.get(item) {\n+ None => return 0,\n+ Some(i) => i,\n+ };\n+ // Map scope children to compact local indices.\n+ let sibling_idxs: Vec = children.iter()\n+ .filter_map(|c| group.item_to_idx.get(c).copied())\n+ .collect();\n+ let global_to_local: HashMap = sibling_idxs.iter()\n+ .enumerate().map(|(l, &g)| (g, l)).collect();\n+ let item_local = match global_to_local.get(&item_global_idx) {\n+ None => return 0,\n+ Some(&l) => l,\n+ };\n+ // Connected components within scope.\n+ let (comps, _) = crate::ranking::connected_components_from_voted_pairs(\n+ sibling_idxs.len(),\n+ group.voted_pairs.iter().filter_map(|(a, b)| {\n+ Some((global_to_local.get(a).copied()?, global_to_local.get(b).copied()?))\n+ }),\n+ );\n+ // Find the component containing this item.\n+ let comp_local = match comps.iter().find(|c| c.contains(&item_local)) {\n+ None => return 0,\n+ Some(c) => c,\n+ };\n+ let comp_global: Vec = comp_local.iter()\n+ .filter_map(|&l| sibling_idxs.get(l).copied())\n+ .collect();\n+ let ranked = crate::ranking::ranked_items_subset(group, &comp_global, 10000, 1e-8);\n+ ranked.iter().position(|r| &r.item == item).map(|i| i + 1).unwrap_or(0)\n+ }\n+\n+ /// 1-indexed position of `item` in the component-aware global flat list.\n+ /// Components sorted largest-first; items ranked within each component.\n+ /// 0 if the item is not in the ranking group.\n+ fn global_rank_of(group: &GroupState, item: &ItemId) -> usize {\n+ if !group.item_to_idx.contains_key(item) {\n+ return 0;\n+ }\n+ let n = group.idx_to_item.len();\n+ let (mut comps, _) = crate::ranking::connected_components_from_voted_pairs(\n+ n, group.voted_pairs.iter().copied(),\n+ );\n+ comps.sort_by_key(|b| std::cmp::Reverse(b.len()));\n+ let mut pos = 1usize;\n+ for comp in &comps {\n+ let ranked = crate::ranking::ranked_items_subset(group, comp, 10000, 1e-8);\n+ for r in &ranked {\n+ if &r.item == item {\n+ return pos;\n+ }\n+ pos += 1;\n+ }\n+ }\n+ 0\n+ }\n+\n+ /// Apply one ingest's DSL effects to `content` (votes, items, snippets, rank history).\n+ fn apply_ingest_to_content(content: &mut ContentState, ing: &Ingest) -> Result<(), ()> {\n+ let doc = dsl::parse_full(&ing.raw).map_err(|_| ())?;\n+ let canonical_thread = canonicalize_tag(&ing.thread_tag);\n+\n+ let voted_items: Vec = doc\n+ .statements\n+ .iter()\n+ .filter_map(|s| {\n+ if let dsl::Stmt::Vote { item1, item2, .. } = s {\n+ Some([item1, item2])\n+ } else {\n+ None\n+ }\n+ })\n+ .flat_map(|pair| pair.into_iter())\n+ .filter_map(|raw| Self::normalize_item(raw))\n+ .collect::>()\n+ .into_iter()\n+ .collect();\n+\n+ let principal = ing.principal.clone();\n+ let delegate = ing.delegate.clone();\n+\n+ let before: HashMap = if !voted_items.is_empty() {\n+ crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n+ voted_items\n+ .iter()\n+ .map(|it| {\n+ (\n+ it.clone(),\n+ (\n+ Self::scope_rank_of(&content.ranking_group, it, &content.item_children),\n+ Self::global_rank_of(&content.ranking_group, it),\n+ ),\n+ )\n+ })\n+ .collect()\n+ } else {\n+ HashMap::new()\n+ };\n+\n+ let mut ingest_items: HashSet = HashSet::new();\n+\n+ for stmt in doc.statements {\n+ match stmt {\n+ dsl::Stmt::Item { title, body } => {\n+ let Some(item) = Self::normalize_item(&title) else {\n+ continue;\n+ };\n+ nav!(content.items, set_elem(item.clone()));\n+ ingest_items.insert(item.clone());\n+ Self::add_child_edge(content, &item);\n+\n+ if let Some(body_text) = body {\n+ if !body_text.trim().is_empty() {\n+ nav!(content.item_bodies, keypath(item.clone()), setval(body_text));\n+ }\n+ }\n+ }\n+ dsl::Stmt::Vote {\n+ item1,\n+ item2,\n+ ratio_left,\n+ ratio_right,\n+ explanation,\n+ } => {\n+ let Some(item_a) = Self::normalize_item(&item1) else {\n+ continue;\n+ };\n+ let Some(item_b) = Self::normalize_item(&item2) else {\n+ continue;\n+ };\n+\n+ let vote = VoteData {\n+ ts: ing.ts,\n+ a: item_a.clone(),\n+ b: item_b.clone(),\n+ ratio_left,\n+ ratio_right,\n+ body: explanation,\n+ principal: principal.clone(),\n+ delegate: delegate.clone(),\n+ thread_tag: canonical_thread.clone(),\n+ };\n+\n+ ingest_items.insert(item_a.clone());\n+ ingest_items.insert(item_b.clone());\n+\n+ nav!(content.items, set_elem(item_a.clone()));\n+ nav!(content.items, set_elem(item_b.clone()));\n+ Self::add_child_edge(content, &item_a);\n+ Self::add_child_edge(content, &item_b);\n+\n+ content.ranking_group.apply_vote(vote.clone());\n+\n+ for it in [&item_a, &item_b] {\n+ nav!(content.item_votes, keypath(it.clone()), push_front(vote.clone()));\n+ }\n+ }\n+ dsl::Stmt::Prose { .. } => {}\n+ }\n+ }\n+\n+ for item in ingest_items.iter() {\n+ nav!(content.item_snippets, keypath(item.clone()), push_front(ing.id.clone()));\n+ }\n+\n+ for item in ingest_items.iter() {\n+ nav!(\n+ content.item_threads,\n+ keypath(item.clone()),\n+ set_elem(canonical_thread.clone())\n+ );\n+ }\n+\n+ if !voted_items.is_empty() {\n+ crate::ranking::compute_group_ranking(&mut content.ranking_group, 10000, 1e-8);\n+ let thread = canonical_thread.clone();\n+ for item in &voted_items {\n+ let after_scope = Self::scope_rank_of(&content.ranking_group, item, &content.item_children);\n+ let after_global = Self::global_rank_of(&content.ranking_group, item);\n+ let score = content\n+ .ranking_group\n+ .item_to_idx\n+ .get(item)\n+ .and_then(|&i| content.ranking_group.cached_scores.get(i))\n+ .copied()\n+ .unwrap_or(0.0);\n+ let (before_scope, before_global) = before.get(item).copied().unwrap_or((0, 0));\n+ let prev = content.rank_history.get(item).and_then(|v| v.last());\n+ let scope_delta = if prev.is_none() {\n+ 0\n+ } else {\n+ after_scope as i32 - before_scope as i32\n+ };\n+ let global_delta = if prev.is_none() {\n+ 0\n+ } else {\n+ after_global as i32 - before_global as i32\n+ };\n+ let scope_total = item\n+ .parent()\n+ .and_then(|p| content.item_children.get(&p))\n+ .map(|s| s.len())\n+ .unwrap_or(0);\n+ let global_total = content.ranking_group.idx_to_item.len();\n+ content.rank_history.entry(item.clone()).or_default().push(RankHistoryEntry {\n+ ts: ing.ts,\n+ scope_rank: after_scope,\n+ scope_rank_delta: scope_delta,\n+ scope_total,\n+ global_rank: after_global,\n+ global_rank_delta: global_delta,\n+ global_total,\n+ score,\n+ thread: thread.clone(),\n+ post_id: ing.id.clone(),\n+ });\n+ }\n+ }\n+\n+ Ok(())\n+ }\n+\n+ fn rebuild_scope_content(&mut self, scope: ScopeId) {\n+ let mut cs = ContentState::default();\n+ for id in &self.ingests_ordered {\n+ if self.redacted_posts.contains(id) {\n+ continue;\n+ }\n+ let Some(ing) = self.ingests_by_id.get(id) else {\n+ continue;\n+ };\n+ if scope_from_room_wire(&ing.room_id) != scope {\n+ continue;\n+ }\n+ let _ = Self::apply_ingest_to_content(&mut cs, ing);\n+ }\n+ self.content.insert(scope, cs);\n+ // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so\n+ // per-post URLs and chronological indices remain stable; only projected garden state resets.\n+ }\n+\n+ /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants).\n+ fn purge_private_room(&mut self, room_id: &str) {\n+ let scope = ScopeId::Room(room_id.to_string());\n+ self.rooms.remove(room_id);\n+ self.grants.remove(room_id);\n+ self.room_timeline.remove(room_id);\n+ self.invites.retain(|_, inv| inv.room_id != room_id);\n+ self.content.remove(&scope);\n+ self.forum_threads.retain(|(s, _), _| s != &scope);\n+ self.ingests_by_scope_thread.retain(|(s, _), _| s != &scope);\n+\n+ let mut to_drop: Vec = self\n+ .ingests_by_id\n+ .iter()\n+ .filter(|(_, ing)| ing.room_id.trim() == room_id)\n+ .map(|(id, _)| id.clone())\n+ .collect();\n+ to_drop.sort();\n+ to_drop.dedup();\n+ for id in to_drop {\n+ if let Some(ing) = self.ingests_by_id.remove(&id) {\n+ self.posts_by_actor\n+ .entry(ing.principal)\n+ .and_modify(|q| {\n+ q.retain(|x| x != &id);\n+ });\n+ }\n+ self.ingests_ordered.retain(|x| x != &id);\n+ self.redacted_posts.remove(&id);\n+ self.post_redact_ts.remove(&id);\n+ }\n+ }\n+\n+ pub fn apply_event(&mut self, event: Event) {\n+ match event {\n+ Event::UserRegistered(ur) => {\n+ self.users_by_provider.insert(\n+ (ur.provider.to_lowercase(), ur.provider_id.clone()),\n+ ur.username,\n+ );\n+ }\n+ Event::TokenIssued(ti) => {\n+ self.tokens_by_id.insert(\n+ ti.token_id.clone(),\n+ (ti.username, ti.salt.clone(), ti.token_hash.clone()),\n+ );\n+ }\n+ Event::AgentBound(ab) => {\n+ if ab.agent.is_empty() {\n+ return;\n+ }\n+ self.agent_bindings.insert(ab.agent, ab.username);\n+ }\n+ Event::RoomCreated(rc) => {\n+ self.rooms.insert(rc.room_id.clone());\n+ self.room_timeline\n+ .entry(rc.room_id.clone())\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: rc.ts,\n+ kind: RoomTimelineKind::RoomCreated {\n+ owner: rc.owner.clone(),\n+ slug: rc.slug.clone(),\n+ },\n+ });\n+ }\n+ Event::RoomDeleted(rd) => {\n+ let room_id = rd.room_id.clone();\n+ if self.rooms.contains(&room_id) {\n+ self.room_timeline\n+ .entry(room_id.clone())\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: rd.ts,\n+ kind: RoomTimelineKind::RoomDeleted {\n+ deleted_by: rd.deleted_by.clone(),\n+ },\n+ });\n+ }\n+ self.purge_private_room(&room_id);\n+ }\n+ Event::Ingest(mut ing) => {\n+ ing.thread_tag = canonicalize_tag(&ing.thread_tag);\n+ let room_key = ing.room_id.trim().to_string();\n+ let scope = scope_from_room_wire(&room_key);\n+ let canonical_thread = ing.thread_tag.clone();\n+ let scope_thread_key = (scope.clone(), canonical_thread.clone());\n+\n+ {\n+ let content = self.content_for_scope_mut(scope.clone());\n+ if Self::apply_ingest_to_content(content, &ing).is_err() {\n+ eprintln!(\n+ \"WARNING: Skipping malformed ingest event {}: parse failed\",\n+ ing.id\n+ );\n+ return;\n+ }\n+ }\n+\n+ self.ingests_by_id.insert(ing.id.clone(), ing.clone());\n+\n+ let ft = self.forum_threads.entry(scope_thread_key.clone()).or_default();\n+ let prev_ts = ft.last_activity_ts;\n+ if ing.ts > prev_ts {\n+ ft.last_activity_ts = ing.ts;\n+ ft.last_actor = ing.principal.clone();\n+ }\n+\n+ nav!(self.ingests_by_scope_thread, keypath(scope_thread_key), push_front(ing.id.clone()));\n+\n+ nav!(self.ingests_ordered, push_back(ing.id.clone()));\n+\n+ nav!(self.posts_by_actor, keypath(ing.principal.clone()), push_back(ing.id.clone()));\n+\n+ nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));\n+ }\n+ Event::PostRedacted(pr) => {\n+ self.redacted_posts.insert(pr.post_id.clone());\n+ self.post_redact_ts.insert(pr.post_id.clone(), pr.ts);\n+ let Some(ing) = self.ingests_by_id.get(&pr.post_id).cloned() else {\n+ return;\n+ };\n+ let scope = scope_from_room_wire(ing.room_id.trim());\n+ // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`.\n+ self.rebuild_scope_content(scope);\n+ }\n+ Event::GrantAdded(ga) => {\n+ let room_id = ga.room_id.clone();\n+ let caps = self.grants\n+ .entry(ga.room_id)\n+ .or_default()\n+ .entry(ga.username.clone())\n+ .or_default();\n+ for cap in ga.capabilities.iter().copied() {\n+ caps.insert(cap);\n+ }\n+ self.room_timeline\n+ .entry(room_id)\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: ga.ts,\n+ kind: RoomTimelineKind::GrantAdded {\n+ username: ga.username.clone(),\n+ granted_by: ga.granted_by.clone(),\n+ capabilities: ga.capabilities.clone(),\n+ },\n+ });\n+ }\n+ Event::GrantRevoked(gr) => {\n+ let room_id = gr.room_id.clone();\n+ if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {\n+ let username = gr.username.clone();\n+ if let Some(caps) = room_grants.get_mut(&username) {\n+ for cap in &gr.capabilities {\n+ caps.remove(cap);\n+ }\n+ if caps.is_empty() {\n+ room_grants.remove(&username);\n+ }\n+ }\n+ if room_grants.is_empty() {\n+ self.grants.remove(&gr.room_id);\n+ }\n+ }\n+ self.room_timeline\n+ .entry(room_id)\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: gr.ts,\n+ kind: RoomTimelineKind::GrantRevoked {\n+ username: gr.username.clone(),\n+ revoked_by: gr.revoked_by.clone(),\n+ capabilities: gr.capabilities.clone(),\n+ },\n+ });\n+ }\n+ Event::InviteMinted(im) => {\n+ self.invites.insert(\n+ im.token.clone(),\n+ ActiveInviteState {\n+ room_id: im.room_id.clone(),\n+ capabilities: im.capabilities.iter().copied().collect(),\n+ inviter: im.inviter.clone(),\n+ uses_remaining: im.max_uses,\n+ expires_ts_ms: im.expires_ts_ms,\n+ },\n+ );\n+ }\n+ Event::InviteRedeemed(ir) => {\n+ if let Some(inv) = self.invites.get_mut(&ir.token) {\n+ inv.uses_remaining = inv.uses_remaining.saturating_sub(1);\n+ if inv.uses_remaining == 0 {\n+ self.invites.remove(&ir.token);\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+impl Default for ReducerState {\n+ fn default() -> Self {\n+ let mut content = HashMap::new();\n+ content.insert(ScopeId::Public, ContentState::default());\n+ Self {\n+ content,\n+ users_by_provider: HashMap::new(),\n+ tokens_by_id: HashMap::new(),\n+ agent_bindings: HashMap::new(),\n+ ingests_by_id: HashMap::new(),\n+ ingests_by_scope_thread: HashMap::new(),\n+ rooms: HashSet::new(),\n+ forum_threads: HashMap::new(),\n+ actor_last_post_ts: HashMap::new(),\n+ ingests_ordered: Vec::new(),\n+ posts_by_actor: HashMap::new(),\n+ redacted_posts: HashSet::new(),\n+ post_redact_ts: HashMap::new(),\n+ grants: HashMap::new(),\n+ room_timeline: HashMap::new(),\n+ invites: HashMap::new(),\n+ }\n+ }\n+}\n+\ndiff --git a/seed.tdsl b/seed.tdsl\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..499a615041e3be5987dcb306ede84f4ce936e0be\n--- /dev/null\n+++ b/seed.tdsl\n@@ -0,0 +1,21 @@\n+reddit sorter (reddit.sorter.social? sorter.social? reddit.slug.social?\n+jsonl based. Same stack as slug\n+fullscreen paginated percentile score of who is the biggest asshole on r/amitheasshole. Click to expand body using js eval\n+Viewcounters for every site\n+Login with Reddit oauth?\n+Streaks? Streak gates? Progressive revelation?\n+One pair of the day\n+Use attributes again, for assholery. To distinguish from reddits single dimension\n+r/sorter_south_park_characters \n+Is a one shot category that only has the characters.\n+subs starting with sorter have special priority in sorter index.\n+\n+layer above reddit?\n+\n+\n+use websocket based bar i had built in afterlife... \n+\n+\n+claude {{\n+What I'd push on hardest: what's the artifact that gets shared? Letterboxd works because every movie has a beautiful URL, every user has a beautiful profile, every list is screenshot-worthy. AITA-but-ranked needs the same. \"This person ranks 97th percentile of asshole, beating 4,891 other Reddit posters this month\" — presented well, that's a tweet, that's a screenshot, that's how the next thousand users find you. Build the share artifact early. Streaks and progressive revelation are for retention; the share-out is for acquisition; you need both but the acquisition piece is what makes streaks worth designing.\n+}}\ndiff --git a/ui_action.rs b/ui_action.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..5e4131dd346a6f80bfe091a9b0cf7902721bc47f\n--- /dev/null\n+++ b/ui_action.rs\n@@ -0,0 +1,270 @@\n+//! Browser-only UI commands: JSON in hidden `__rpc__` plus hole fill ([`crate::form_template`]).\n+//! Not part of [`slug_types::RpcCommand`] (CLI / JSON API).\n+\n+use crate::form_template::fill_template_from_form;\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+use std::collections::HashMap;\n+use thiserror::Error;\n+\n+/// Form field name for the compact JSON template (possibly with `{\"$form\":\"…\"}` holes).\n+pub const UI_RPC_FIELD: &str = \"__rpc__\";\n+\n+fn default_ui_form_action() -> String {\n+ \"/ui\".to_string()\n+}\n+\n+/// HTML form / fetch `POST /ui` payload after template fill and deserialization.\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+#[serde(tag = \"action\", rename_all = \"snake_case\")]\n+pub enum HtmlUiAction {\n+ /// Forum ingest via `POST /ui`.\n+ PostIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ #[serde(default)]\n+ error_target: Option,\n+ #[serde(default)]\n+ form_id: Option,\n+ },\n+ /// DSL check / validation via `POST /ui`.\n+ CheckIngest {\n+ room: String,\n+ thread_tag: String,\n+ text: String,\n+ #[serde(default)]\n+ error_target: Option,\n+ #[serde(default)]\n+ form_id: Option,\n+ },\n+ /// Post a pairwise vote from `/vote` (browser compose).\n+ VoteComparePost {\n+ room: String,\n+ thread_tag: String,\n+ left_item: String,\n+ right_item: String,\n+ /// From form fields (string); parsed server-side.\n+ ratio_left: String,\n+ ratio_right: String,\n+ explanation: String,\n+ /// Same-origin path after successful post (JS redirect).\n+ next: String,\n+ /// Pool parent item path, if the vote was initiated from a pool URL.\n+ #[serde(default)]\n+ pool: Option,\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n+ },\n+ /// Set or clear the garden HUD pin cookie (`slug_garden_pin`). Response is **`303` + `Set-Cookie`** when submitted as a full-navigation form (`data-navigate=\"full\"`), matching `POST /theme`.\n+ SetGardenPin {\n+ #[serde(default)]\n+ clear: bool,\n+ #[serde(default)]\n+ room_wire: String,\n+ #[serde(default)]\n+ item_storage: Option,\n+ next: String,\n+ /// Must equal the form `action` (usually `/ui`). Used to reject forged requests that POST to another path.\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n+ },\n+ /// Resolve children or siblings for an external garden item.\n+ ResolveExternal {\n+ room_wire: String,\n+ item_storage: String,\n+ mode: String,\n+ next: String,\n+ #[serde(default = \"default_ui_form_action\")]\n+ form_action: String,\n+ },\n+ /// Author redacts own post via `POST /ui`.\n+ RedactPost { post_id: String },\n+ /// Morph `#room-members-section` — members list open or collapsed (server-rendered).\n+ SetRoomMembersExpanded {\n+ room_wire: String,\n+ #[serde(default)]\n+ expanded: bool,\n+ },\n+ /// Delete the private room (Manage only); redirects to `/` on success.\n+ DeleteRoom { room: String },\n+ /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: \"public\"` for home).\n+ SetNewThreadComposeExpanded {\n+ room_wire: String,\n+ #[serde(default)]\n+ expanded: bool,\n+ },\n+ /// Replace a truncated post card with the full body (same thread index).\n+ ExpandPostFull {\n+ room: String,\n+ thread_tag: String,\n+ post_index: usize,\n+ },\n+ /// Expand a redacted/tombstone post to show stripped body (author view).\n+ ExpandRedactedPost {\n+ room: String,\n+ thread_tag: String,\n+ post_index: usize,\n+ },\n+ /// Collapse an expanded redacted post back to the tombstone card.\n+ CollapseRedactedPost {\n+ room: String,\n+ thread_tag: String,\n+ post_index: usize,\n+ },\n+ /// Copy full thread text (CLI `forum show` format) to the clipboard.\n+ CopyThread {\n+ room: String,\n+ thread_tag: String,\n+ copy_btn_id: String,\n+ },\n+}\n+\n+#[derive(Debug, Error)]\n+pub enum HtmlUiParseError {\n+ #[error(\"missing __rpc__ field\")]\n+ MissingRpc,\n+ #[error(\"invalid template json: {0}\")]\n+ Template(serde_json::Error),\n+ #[error(\"invalid ui action: {0}\")]\n+ Action(serde_json::Error),\n+}\n+\n+/// Parse `__rpc__` JSON, apply `$form` holes from the rest of the form map, deserialize.\n+pub fn parse_html_ui_from_form(\n+ form: &HashMap,\n+) -> Result {\n+ let template = form.get(UI_RPC_FIELD).ok_or(HtmlUiParseError::MissingRpc)?;\n+ let mut hole_map = form.clone();\n+ hole_map.remove(UI_RPC_FIELD);\n+ let v: Value =\n+ fill_template_from_form(template, &hole_map).map_err(HtmlUiParseError::Template)?;\n+ serde_json::from_value(v).map_err(HtmlUiParseError::Action)\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn round_trip_post_ingest_with_holes() {\n+ let template = serde_json::json!({\n+ \"action\": \"post_ingest\",\n+ \"room\": \"public\",\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"text\": {\"$form\": \"text\"},\n+ \"error_target\": \"e\",\n+ \"form_id\": \"f\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ form.insert(\"thread_tag\".into(), \"x\".into());\n+ form.insert(\"text\".into(), \"body\".into());\n+\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::PostIngest {\n+ room: \"public\".into(),\n+ thread_tag: \"x\".into(),\n+ text: \"body\".into(),\n+ error_target: Some(\"e\".into()),\n+ form_id: Some(\"f\".into()),\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn expand_post_full_round_trip() {\n+ let template = serde_json::json!({\n+ \"action\": \"expand_post_full\",\n+ \"room\": \"public\",\n+ \"thread_tag\": \"demo\",\n+ \"post_index\": 3,\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::ExpandPostFull {\n+ room: \"public\".into(),\n+ thread_tag: \"demo\".into(),\n+ post_index: 3,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn set_room_members_expanded_defaults_false() {\n+ let template = serde_json::json!({\n+ \"action\": \"set_room_members_expanded\",\n+ \"room_wire\": \"ab/cd\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::SetRoomMembersExpanded {\n+ room_wire: \"ab/cd\".into(),\n+ expanded: false,\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn copy_thread_round_trip() {\n+ let template = serde_json::json!({\n+ \"action\": \"copy_thread\",\n+ \"room\": \"public\",\n+ \"thread_tag\": \"demo\",\n+ \"copy_btn_id\": \"thread-copy-top\",\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::CopyThread {\n+ room: \"public\".into(),\n+ thread_tag: \"demo\".into(),\n+ copy_btn_id: \"thread-copy-top\".into(),\n+ }\n+ );\n+ }\n+\n+ #[test]\n+ fn set_new_thread_compose_expanded_true() {\n+ let template = serde_json::json!({\n+ \"action\": \"set_new_thread_compose_expanded\",\n+ \"room_wire\": \"ab/cd\",\n+ \"expanded\": true,\n+ });\n+ let mut form = HashMap::new();\n+ form.insert(\n+ UI_RPC_FIELD.to_string(),\n+ serde_json::to_string(&template).unwrap(),\n+ );\n+ let a = parse_html_ui_from_form(&form).unwrap();\n+ assert_eq!(\n+ a,\n+ HtmlUiAction::SetNewThreadComposeExpanded {\n+ room_wire: \"ab/cd\".into(),\n+ expanded: true,\n+ }\n+ );\n+ }\n+}\ndiff --git a/vote.rs b/vote.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d0ec78cd675eae284d056fb3b8eaf5cc853d6263\n--- /dev/null\n+++ b/vote.rs\n@@ -0,0 +1,580 @@\n+use axum::{\n+ extract::{Path, Query, State},\n+ http::{HeaderMap, StatusCode, Uri},\n+ response::{Html, IntoResponse},\n+};\n+use axum_extra::extract::cookie::CookieJar;\n+use maud::html;\n+use serde::Deserialize;\n+use serde_json::json;\n+use std::collections::{HashMap, HashSet};\n+\n+use crate::{\n+ api::optional_principal,\n+ canonical_path::canonicalize_tag,\n+ form_template::template_json_compact,\n+ html::{\n+ forum::ThreadNav,\n+ layout_full_bleed_chromeless,\n+ ratio_pct, render_item_body_in_scope,\n+ theme_from_jar, theme_next_from_uri,\n+ user_can_post_room,\n+ ui_action::UI_RPC_FIELD,\n+ JsBuilder,\n+ },\n+ middleware::canonical_view_url,\n+ path_types::ItemId,\n+ reducer::{ContentState, ScopeId},\n+ scope_rank::suggest_next_pair_in_pool,\n+ state::AppState,\n+};\n+\n+use super::{\n+ access::{content_for_garden_view, room_not_found_page, room_scope_has_garden_content, user_can_view_room},\n+ item::{item_display_path, login_href_with_next},\n+};\n+\n+fn pick_autothread_for_vote_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> String {\n+ let cands: HashSet = content\n+ .item_threads\n+ .get(a)\n+ .into_iter()\n+ .chain(content.item_threads.get(b))\n+ .flat_map(|s| s.iter().cloned())\n+ .collect();\n+ if cands.is_empty() {\n+ return \"vote\".to_string();\n+ }\n+ let mut v: Vec = cands.into_iter().collect();\n+ v.sort();\n+ canonicalize_tag(&v[0])\n+}\n+\n+/// Canonical unordered pair: lexicographic by storage string (stable edge identity).\n+pub(super) fn canonical_edge_items(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) {\n+ let ac = a.clone().normalized_storage();\n+ let bc = b.clone().normalized_storage();\n+ if ac.as_str() <= bc.as_str() {\n+ (ac, bc)\n+ } else {\n+ (bc, ac)\n+ }\n+}\n+\n+/// All votes whose endpoints are exactly this unordered pair (unsorted).\n+pub(super) fn edge_vote_entries_for_pair(\n+ content: &ContentState,\n+ a: &ItemId,\n+ b: &ItemId,\n+) -> Vec {\n+ let (lo, hi) = canonical_edge_items(a, b);\n+ let lo_s = lo.as_str();\n+ let hi_s = hi.as_str();\n+ content\n+ .item_votes\n+ .get(&lo)\n+ .into_iter()\n+ .flat_map(|q| q.iter())\n+ .filter(|v| {\n+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)\n+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)\n+ })\n+ .cloned()\n+ .collect()\n+}\n+\n+pub(super) fn ratios_for_compare_page(\n+ v: &crate::reducer::VoteData,\n+ page_left: &ItemId,\n+ page_right: &ItemId,\n+) -> (i32, i32) {\n+ let pl = page_left.as_str();\n+ let pr = page_right.as_str();\n+ match (v.a.as_str(), v.b.as_str()) {\n+ (a, b) if a == pl && b == pr => (v.ratio_left, v.ratio_right),\n+ (a, b) if a == pr && b == pl => (v.ratio_right, v.ratio_left),\n+ _ => (v.ratio_left, v.ratio_right),\n+ }\n+}\n+\n+fn left_share_normalized(ratio_left: i32, ratio_right: i32) -> f64 {\n+ let l = ratio_left.max(0) as f64;\n+ let r = ratio_right.max(0) as f64;\n+ let sum = l + r;\n+ if sum <= 0.0 {\n+ 0.5\n+ } else {\n+ l / sum\n+ }\n+}\n+\n+/// Stronger preference for **`page_left` first**; ties **newer first**.\n+pub(super) fn sort_votes_for_compare_display(\n+ mut votes: Vec,\n+ page_left: &ItemId,\n+ page_right: &ItemId,\n+) -> Vec {\n+ votes.sort_by(|va, vb| {\n+ let (ratio_left_a, ratio_right_a) = ratios_for_compare_page(va, page_left, page_right);\n+ let (ratio_left_b, ratio_right_b) = ratios_for_compare_page(vb, page_left, page_right);\n+ let sa = left_share_normalized(ratio_left_a, ratio_right_a);\n+ let sb = left_share_normalized(ratio_left_b, ratio_right_b);\n+ match sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal) {\n+ std::cmp::Ordering::Equal => vb.ts.cmp(&va.ts),\n+ o => o,\n+ }\n+ });\n+ votes\n+}\n+\n+/// Number of vote ingests recorded for this unordered pair in `content` (same scope as ranking).\n+pub(super) fn edge_vote_count_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> usize {\n+ let (lo, hi) = canonical_edge_items(a, b);\n+ let lo_s = lo.as_str();\n+ let hi_s = hi.as_str();\n+ content\n+ .item_votes\n+ .get(&lo)\n+ .into_iter()\n+ .flat_map(|q| q.iter())\n+ .filter(|v| {\n+ (v.a.as_str() == lo_s && v.b.as_str() == hi_s)\n+ || (v.a.as_str() == hi_s && v.b.as_str() == lo_s)\n+ })\n+ .count()\n+}\n+\n+fn vote_thread_tags_for_pair(content: &ContentState, a: &ItemId, b: &ItemId) -> Vec {\n+ let set: HashSet = content\n+ .item_threads\n+ .get(a)\n+ .into_iter()\n+ .chain(content.item_threads.get(b))\n+ .flat_map(|s| s.iter().cloned())\n+ .collect();\n+ let mut v: Vec = set.into_iter().collect();\n+ v.sort();\n+ v.into_iter().map(|t| canonicalize_tag(&t)).collect()\n+}\n+\n+fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemId) -> maud::Markup {\n+ let votes = edge_vote_entries_for_pair(content, left, right);\n+ let votes = sort_votes_for_compare_display(votes, left, right);\n+ let legend_left = item_display_path(left.as_str());\n+ let legend_right = item_display_path(right.as_str());\n+ html! {\n+ @if votes.is_empty() {\n+ p class=\"muted vote-edge-empty\" { \"no votes on this pair in this scope yet\" }\n+ } @else {\n+ h3 class=\"vote-edge-history-title\" {\n+ \"votes on this edge\"\n+ span class=\"vote-edge-history-axis muted\" { \" · \" (legend_left) \" : \" (legend_right) }\n+ }\n+ ul class=\"vote-edge-history\" {\n+ @for v in &votes {\n+ @let (r_left, r_right) = ratios_for_compare_page(v, left, right);\n+ @let pct = ratio_pct(r_left, r_right);\n+ @let row_tip = format!(\n+ \"{}:{} counts toward {} (left of bar) vs {} (right of bar); #{} · @{}\",\n+ r_left,\n+ r_right,\n+ legend_left,\n+ legend_right,\n+ v.thread_tag,\n+ v.principal,\n+ );\n+ li class=\"vote-edge-history-row\" title=(row_tip) {\n+ div class=\"vote-edge-meta\" {\n+ span class=\"vote-edge-ratio\" { (format!(\"{}:{}\", r_left, r_right)) }\n+ span class=\"muted\" { \" · #\" (v.thread_tag) \" · @\" (v.principal) }\n+ }\n+ div class=\"ratio-bar vote-edge-bar\" aria-hidden=\"true\" {\n+ div class=\"ratio-left\" style={(format!(\"width: {:.3}%;\", pct))} {}\n+ div class=\"ratio-right\" style={(format!(\"width: {:.3}%;\", 100.0 - pct))} {}\n+ }\n+ @if !v.body.trim().is_empty() {\n+ div class=\"vote-edge-reason muted\" { (v.body.trim()) }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+}\n+\n+/// After a successful vote post: refresh edge history (no in-page preview card).\n+#[allow(clippy::too_many_arguments)]\n+pub(crate) async fn vote_compare_post_success_js(\n+ state: &AppState,\n+ nav: &ThreadNav,\n+ _room_wire: &str,\n+ _thread_tag: &str,\n+ left: &ItemId,\n+ right: &ItemId,\n+ pool: Option<&ItemId>,\n+ _post_id: &str,\n+ _post_idx: Option,\n+) -> String {\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let edge_history = vote_edge_history_markup(content, left, right);\n+ let next_pair = suggest_next_vote_pair(content, left, right, pool);\n+ let nav_markup = vote_compare_nav_markup(nav, next_pair.as_ref(), pool);\n+ drop(reduced);\n+ JsBuilder::new()\n+ .morph_inner_selector(\"#vote-edge-history-region\", edge_history)\n+ .morph_selector(\".vote-compare-nav\", nav_markup)\n+ .build()\n+}\n+\n+pub(super) fn vote_compare_href(\n+ nav: &ThreadNav,\n+ left: &ItemId,\n+ right: &ItemId,\n+ thread_override: Option<&str>,\n+ pool: Option<&ItemId>,\n+) -> String {\n+ let left_dp = left.display_path();\n+ let right_dp = right.display_path();\n+ let left_q = urlencoding::encode(&left_dp);\n+ let right_q = urlencoding::encode(&right_dp);\n+ let mut base = format!(\n+ \"{}/vote?left={}&right={}\",\n+ nav.room_path_prefix_for_vote_compare(),\n+ left_q,\n+ right_q\n+ );\n+ if let Some(t) = thread_override.filter(|s| !s.is_empty()) {\n+ base = format!(\"{}&thread={}\", base, urlencoding::encode(t));\n+ }\n+ if let Some(p) = pool {\n+ let pool_dp = p.display_path();\n+ base = format!(\"{}&pool={}\", base, urlencoding::encode(&pool_dp));\n+ }\n+ base\n+}\n+\n+pub(super) fn vote_pool_href(nav: &ThreadNav, pool_item_str: &str) -> String {\n+ let display = ItemId::parse(pool_item_str)\n+ .map(|i| i.display_path())\n+ .unwrap_or_else(|| pool_item_str.to_string());\n+ format!(\n+ \"{}/vote?pool={}\",\n+ nav.room_path_prefix_for_vote_compare(),\n+ urlencoding::encode(&display)\n+ )\n+}\n+\n+fn vote_compare_nav_markup(\n+ nav: &ThreadNav,\n+ next_pair: Option<&(ItemId, ItemId)>,\n+ pool: Option<&ItemId>,\n+) -> maud::Markup {\n+ let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None, pool));\n+ html! {\n+ div class=\"vote-compare-nav\" {\n+ @if let Some(href) = &next_pair_href {\n+ a class=\"vote-compare-next\" data-testid=\"vote-next-pair\" href=(href) { \"next pair\" }\n+ } @else {\n+ span class=\"vote-compare-next is-disabled\" { \"no next pair\" }\n+ }\n+ }\n+ }\n+}\n+\n+pub(super) fn suggest_next_vote_pair(\n+ content: &ContentState,\n+ current_left: &ItemId,\n+ current_right: &ItemId,\n+ pool_parent: Option<&ItemId>,\n+) -> Option<(ItemId, ItemId)> {\n+ let pool: Vec = if let Some(parent) = pool_parent {\n+ content\n+ .item_children\n+ .get(parent)\n+ .map(|s| s.iter().cloned().collect())\n+ .unwrap_or_default()\n+ } else if current_left.parent().as_ref().map(|p| p.as_str())\n+ == current_right.parent().as_ref().map(|p| p.as_str())\n+ {\n+ current_left\n+ .parent()\n+ .and_then(|parent| {\n+ content\n+ .item_children\n+ .get(&parent.normalized_storage())\n+ .cloned()\n+ })\n+ .map(|children| children.into_iter().collect())\n+ .unwrap_or_default()\n+ } else {\n+ Vec::new()\n+ };\n+ if pool.len() < 2 {\n+ return None;\n+ }\n+ suggest_next_pair_in_pool(\n+ &content.ranking_group,\n+ &pool,\n+ Some((current_left, current_right)),\n+ )\n+}\n+\n+pub(super) fn vote_compare_item_card(\n+ nav: &ThreadNav,\n+ item: &ItemId,\n+ body: Option<&String>,\n+ side_class: &str,\n+ item_bodies: Option<&HashMap>,\n+) -> maud::Markup {\n+ html! {\n+ div class=(format!(\"vote-compare-side {side_class}\")) {\n+ a class=(format!(\"vote-compare-item {side_class}\")) href=(nav.garden_item_href(item)) {\n+ code { (item_display_path(item.as_str())) }\n+ }\n+ @if let Some(body) = body.filter(|b| !b.trim().is_empty()) {\n+ div class=\"vote-compare-item-body\" {\n+ (render_item_body_in_scope(\n+ body,\n+ nav.garden_root_url(),\n+ item_bodies,\n+ ))\n+ }\n+ } @else {\n+ p class=\"muted vote-compare-item-body-empty\" { \"no body yet\" }\n+ }\n+ }\n+ }\n+}\n+#[derive(Debug, Deserialize)]\n+pub struct VoteCompareQuery {\n+ #[serde(default)]\n+ pub left: Option,\n+ #[serde(default)]\n+ pub right: Option,\n+ #[serde(default)]\n+ pub thread: Option,\n+ #[serde(default)]\n+ pub pool: Option,\n+}\n+\n+/// Public pairwise vote UI — `/vote?left=&right=&thread=`.\n+pub async fn vote_compare_page(\n+ State(state): State,\n+ Query(q): Query,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let nav = ThreadNav::public();\n+ vote_compare_inner(state, q, nav, headers, jar, uri).await\n+}\n+\n+pub async fn room_vote_compare_page(\n+ State(state): State,\n+ Path(room_key): Path,\n+ Query(q): Query,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> impl IntoResponse {\n+ let Some(room_id) = slug_types::room_id_from_route_segment(&room_key) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n+ let Some(nav) = ThreadNav::from_room_id(&room_id) else {\n+ return (StatusCode::NOT_FOUND, \"bad room path\").into_response();\n+ };\n+ let reduced = state.reduced.read().await;\n+ let user = optional_principal(&headers, &jar, &reduced);\n+ if !user_can_view_room(&reduced, &room_id, user.as_deref()) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ if !room_scope_has_garden_content(&reduced, &nav) {\n+ drop(reduced);\n+ return room_not_found_page(&jar, &uri).into_response();\n+ }\n+ drop(reduced);\n+ vote_compare_inner(state, q, nav, headers, jar, uri).await\n+}\n+\n+async fn vote_compare_inner(\n+ state: AppState,\n+ q: VoteCompareQuery,\n+ nav: ThreadNav,\n+ headers: HeaderMap,\n+ jar: CookieJar,\n+ uri: Uri,\n+) -> axum::response::Response {\n+ let pool_id: Option = match q.pool.as_deref() {\n+ Some(p) => match ItemId::parse(p.trim()) {\n+ Some(i) => Some(i.normalized_storage()),\n+ None => return (StatusCode::BAD_REQUEST, \"bad pool item\").into_response(),\n+ },\n+ None => None,\n+ };\n+\n+ let (left, right) = match (q.left.as_deref(), q.right.as_deref()) {\n+ (Some(l), Some(r)) => {\n+ let left = match ItemId::parse(l.trim()) {\n+ Some(i) => i.normalized_storage(),\n+ None => return (StatusCode::NOT_FOUND, \"bad left item\").into_response(),\n+ };\n+ let right = match ItemId::parse(r.trim()) {\n+ Some(i) => i.normalized_storage(),\n+ None => return (StatusCode::NOT_FOUND, \"bad right item\").into_response(),\n+ };\n+ if left == right {\n+ return (StatusCode::BAD_REQUEST, \"items must differ\").into_response();\n+ }\n+ (left, right)\n+ }\n+ (None, None) => {\n+ let Some(pool) = pool_id.as_ref() else {\n+ return (StatusCode::BAD_REQUEST, \"provide left+right or pool\").into_response();\n+ };\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let children: Vec = content\n+ .item_children\n+ .get(pool)\n+ .map(|s| s.iter().cloned().collect())\n+ .unwrap_or_default();\n+ if children.len() < 2 {\n+ drop(reduced);\n+ return (StatusCode::BAD_REQUEST, \"pool has fewer than 2 children to compare\").into_response();\n+ }\n+ let pair = suggest_next_pair_in_pool(&content.ranking_group, &children, None);\n+ drop(reduced);\n+ match pair {\n+ Some(p) => p,\n+ None => return (StatusCode::BAD_REQUEST, \"no pairs available in pool\").into_response(),\n+ }\n+ }\n+ _ => return (StatusCode::BAD_REQUEST, \"provide both left and right, or just pool\").into_response(),\n+ };\n+\n+ let reduced = state.reduced.read().await;\n+ let content = content_for_garden_view(&reduced, &nav.scope());\n+ let viewer = optional_principal(&headers, &jar, &reduced);\n+ let can_post = match &nav.scope() {\n+ ScopeId::Public => viewer.is_some(),\n+ ScopeId::Room(rid) => viewer\n+ .as_ref()\n+ .map(|u| user_can_post_room(&reduced, rid, u))\n+ .unwrap_or(false),\n+ };\n+ let auto_thread = q\n+ .thread\n+ .as_ref()\n+ .map(|t| canonicalize_tag(t))\n+ .filter(|t| !t.is_empty())\n+ .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right));\n+ let thread_tags = vote_thread_tags_for_pair(content, &left, &right);\n+ let edge_history = vote_edge_history_markup(content, &left, &right);\n+ let left_body = content.item_bodies.get(&left).cloned();\n+ let right_body = content.item_bodies.get(&right).cloned();\n+ let item_bodies_for_cards = content.item_bodies.clone();\n+ let next_pair = suggest_next_vote_pair(content, &left, &right, pool_id.as_ref());\n+ drop(reduced);\n+\n+ let title = format!(\n+ \"vote — {} vs {}\",\n+ item_display_path(left.as_str()),\n+ item_display_path(right.as_str())\n+ );\n+ let next_path = uri\n+ .path_and_query()\n+ .map(|pq| pq.as_str().to_string())\n+ .unwrap_or_else(|| \"/vote\".into());\n+\n+ let rpc_json = template_json_compact(&json!({\n+ \"action\": \"vote_compare_post\",\n+ \"room\": nav.room_wire,\n+ \"thread_tag\": {\"$form\": \"thread_tag\"},\n+ \"left_item\": left.as_str(),\n+ \"right_item\": right.as_str(),\n+ \"ratio_left\": {\"$form\": \"ratio_left\"},\n+ \"ratio_right\": {\"$form\": \"ratio_right\"},\n+ \"explanation\": {\"$form\": \"explanation\"},\n+ \"next\": next_path,\n+ \"pool\": pool_id.as_ref().map(|p| p.as_str()),\n+ \"form_action\": \"/ui\",\n+ }))\n+ .expect(\"vote compare rpc json\");\n+\n+ let body = html! {\n+ section class=\"vote-compare-shell\" {\n+ h2 { \"compare\" }\n+ div class=\"vote-compare-pair\" {\n+ (vote_compare_item_card(\n+ &nav,\n+ &left,\n+ left_body.as_ref(),\n+ \"vote-compare-left\",\n+ Some(&item_bodies_for_cards),\n+ ))\n+ span class=\"vote-compare-vs\" { \"vs\" }\n+ (vote_compare_item_card(\n+ &nav,\n+ &right,\n+ right_body.as_ref(),\n+ \"vote-compare-right\",\n+ Some(&item_bodies_for_cards),\n+ ))\n+ }\n+ (vote_compare_nav_markup(&nav, next_pair.as_ref(), pool_id.as_ref()))\n+ div id=\"vote-edge-history-region\" {\n+ (edge_history)\n+ }\n+ @if can_post {\n+ form id=\"vote-compare-form\" method=\"POST\" action=\"/ui\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc_json);\n+ div class=\"vote-thread-picker\" {\n+ label class=\"vote-thread-picker-label\" { \"thread\" }\n+ select id=\"vote-thread-select\" name=\"thread_tag\" aria-label=\"Thread to post vote into\" {\n+ @if thread_tags.is_empty() {\n+ option value=\"vote\" selected { \"#vote\" }\n+ }\n+ @for t in &thread_tags {\n+ @if *t == auto_thread {\n+ option value=(t) selected { \"#\" (t) }\n+ } @else {\n+ option value=(t) { \"#\" (t) }\n+ }\n+ }\n+ }\n+ }\n+ input type=\"hidden\" name=\"ratio_left\" id=\"vote-ratio-left\" value=\"50\";\n+ input type=\"hidden\" name=\"ratio_right\" id=\"vote-ratio-right\" value=\"50\";\n+ label class=\"vote-compare-slider-label\" {\n+ span id=\"vote-slider-left-label\" { (item_display_path(left.as_str())) }\n+ input type=\"range\" id=\"vote-preference-slider\" min=\"0\" max=\"100\" value=\"50\"\n+ aria-valuemin=\"0\" aria-valuemax=\"100\";\n+ span id=\"vote-slider-right-label\" { (item_display_path(right.as_str())) }\n+ }\n+ label class=\"vote-explain-label\" { \"reason (required)\" }\n+ textarea name=\"explanation\" id=\"vote-explain\" rows=\"5\" placeholder=\"why this split?\" required {}\n+ div id=\"vote-compare-errors\" {}\n+ p { button type=\"submit\" { \"post vote\" } }\n+ }\n+ } @else {\n+ p class=\"muted\" { a href=(login_href_with_next(&next_path)) { \"log in\" } \" to post this vote.\" }\n+ }\n+ }\n+ };\n+\n+ let url_key = canonical_view_url(&uri);\n+ let view_count = state.views.get_views(&url_key);\n+\n+ let page = layout_full_bleed_chromeless(\n+ &title,\n+ \"view-ontology view-ontology-light view-vote-compare view-vote-compare-fullscreen\",\n+ body,\n+ Some(view_count),\n+ theme_from_jar(&jar),\n+ &theme_next_from_uri(&uri),\n+ );\n+ Html(page.into_string()).into_response()\n+}\n+\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[1efb7222] multi-repository git discovery\n\nMake contribution discovery deterministic across repository and branch DAGs, with replayable attribution and adversarial coverage.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/constitution.py b/constitution.py\nindex b535faad658c553b3b9046e2401333618e212150..4bee9f83663ab7fb36db95129b92b64b4ef57258 100644\n--- a/constitution.py\n+++ b/constitution.py\n@@ -6,7 +6,7 @@\n # \"uvicorn\",\n # \"httpx\",\n # \"tenacity\",\n-# \"evaleval>=0.2.6\",\n+# \"evaleval==0.2.7\",\n # \"authlib\",\n # \"itsdangerous\",\n # \"starlette\",\n@@ -29,7 +29,7 @@ from datetime import datetime, timezone\n from fastapi import FastAPI, Request, Response\n from fastapi.responses import PlainTextResponse, HTMLResponse\n from starlette.middleware.sessions import SessionMiddleware\n-import json, time, os, asyncio, httpx, pathlib\n+import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl\n import sympy as sp # type: ignore[reportMissingImports]\n from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential\n from evaleval import (\n@@ -118,11 +118,35 @@ JSONL_PATH = pathlib.Path(os.environ.get(\"JSONL_PATH\", \"/data/ledger.jsonl\"))\n \n GITHUB_CLIENT_ID = os.environ.get(\"GITHUB_CLIENT_ID\", \"\")\n GITHUB_CLIENT_SECRET = os.environ.get(\"GITHUB_CLIENT_SECRET\", \"\")\n-REPO = os.environ.get(\"REPO\", \"tommy-mor/slug\")\n \n OPENROUTER_API_KEY = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n OPENROUTER_BASE_URL = os.environ.get(\"OPENROUTER_BASE_URL\", \"https://openrouter.ai\").rstrip(\"/\")\n-GITHUB_API_BASE_URL = os.environ.get(\"GITHUB_API_BASE_URL\", \"https://api.github.com\").rstrip(\"/\")\n+\n+# Repositories, branches, and contributor identities are constitutional inputs.\n+# A ref pattern matches a complete Git ref: * stays within one path component,\n+# while ** crosses slashes, so refs/heads/** includes branches on branches.\n+# Environment overrides exist for deterministic integration tests and deployments\n+# using the exact same source; their normalized values are committed to every\n+# discovery event.\n+DEFAULT_REPOSITORIES = [\n+ {\n+ \"id\": \"slug\",\n+ \"url\": \"https://github.com/tommy-mor/slug.git\",\n+ \"refs\": [\"refs/heads/**\"],\n+ },\n+]\n+DEFAULT_CONTRIBUTORS = {\n+ \"tommy-mor\": [\"thmorriss@gmail.com\"],\n+}\n+\n+REPOSITORIES = json.loads(\n+ os.environ.get(\"REPOSITORIES_JSON\", json.dumps(DEFAULT_REPOSITORIES))\n+)\n+CONTRIBUTORS = json.loads(\n+ os.environ.get(\"CONTRIBUTORS_JSON\", json.dumps(DEFAULT_CONTRIBUTORS))\n+)\n+GIT_MIRROR_DIR = pathlib.Path(os.environ.get(\"GIT_MIRROR_DIR\", \"/data/git\"))\n+GIT_TIMEOUT_SECONDS = int(os.environ.get(\"GIT_TIMEOUT_SECONDS\", \"120\"))\n \n # Council model IDs: slug.social garden rank under this parent (bodies = OpenRouter URLs), then top-up from OpenRouter list.\n SLUG_SOCIAL_BASE_URL = os.environ.get(\"SLUG_SOCIAL_BASE_URL\", \"https://slug.social\").rstrip(\"/\")\n@@ -146,6 +170,7 @@ class Emission:\n distributions: dict # author -> amount str\n ranking: dict # author -> score str\n models_used: list\n+ discovery_snapshot_id: str = \"\" # empty only for pre-discovery ledger history\n \n \n @event\n@@ -163,6 +188,20 @@ class Redemption:\n amount: str\n \n \n+@event\n+class GitDiscovery:\n+ schema_version: int\n+ epoch: int\n+ snapshot_id: str\n+ timestamp_ms: int\n+ config_digest: str\n+ initial_snapshot: bool\n+ configuration: dict\n+ repositories: list\n+ observations: list\n+ commits: list\n+\n+\n store = JsonlStore(JSONL_PATH)\n \n \n@@ -532,44 +571,426 @@ Side B — unified diffs (full patches):\n return json.loads(content)\n \n \n-def _github_headers():\n- h = {\"Accept\": \"application/vnd.github+json\"}\n- tok = os.environ.get(\"GITHUB_TOKEN\", \"\")\n- if tok:\n- h[\"Authorization\"] = f\"Bearer {tok}\"\n- return h\n+# ===========================================================================\n+# §4b. GIT DISCOVERY — immutable reachability snapshots across repositories\n+# ===========================================================================\n+#\n+# Git timestamps cannot prove when a branch first reached a commit. The first\n+# snapshot therefore bootstraps history by committer time at GENESIS_MS. Every\n+# later snapshot uses the stronger rule: a commit enters exactly once, when it\n+# first becomes reachable from the union of configured refs.\n+#\n+# OIDs are deduplicated globally, then equivalent cherry-picks are deduplicated\n+# by Git's stable patch identity. Merges and empty commits are graph structure,\n+# not separately priced contributions. Discovery is all-or-nothing: if any\n+# repository cannot be mirrored and verified, no snapshot is appended.\n+\n+GIT_DISCOVERY_SCHEMA_VERSION = 1\n+PATCH_IDENTITY_VERSION = \"git-patch-id-stable-v1\"\n+_DISCOVERY_LOCK = asyncio.Lock()\n+\n+\n+def _normalized_discovery_config() -> dict:\n+ repositories = []\n+ seen_ids = set()\n+ for raw in REPOSITORIES:\n+ repo_id = str(raw.get(\"id\", \"\"))\n+ url = str(raw.get(\"url\", \"\"))\n+ refs = sorted(set(str(x) for x in raw.get(\"refs\", [])))\n+ if not re.fullmatch(r\"[A-Za-z0-9._-]+\", repo_id):\n+ raise ValueError(f\"invalid repository id: {repo_id!r}\")\n+ if repo_id in seen_ids:\n+ raise ValueError(f\"duplicate repository id: {repo_id}\")\n+ if not url or not refs or any(not r.startswith(\"refs/\") for r in refs):\n+ raise ValueError(f\"repository {repo_id} requires a URL and full ref patterns\")\n+ seen_ids.add(repo_id)\n+ repositories.append({\"id\": repo_id, \"url\": url, \"refs\": refs})\n+\n+ email_to_contributor = {}\n+ contributors = {}\n+ for contributor, emails in sorted(CONTRIBUTORS.items()):\n+ contributor = str(contributor)\n+ normalized = sorted(set(str(e).strip().lower() for e in emails))\n+ if not contributor or not normalized:\n+ raise ValueError(\"contributors require an id and at least one email\")\n+ for email in normalized:\n+ if email in email_to_contributor:\n+ raise ValueError(f\"email belongs to multiple contributors: {email}\")\n+ email_to_contributor[email] = contributor\n+ contributors[contributor] = normalized\n+\n+ repositories.sort(key=lambda r: r[\"id\"])\n+ return {\"repositories\": repositories, \"contributors\": contributors}\n+\n+\n+def _config_digest(config: dict) -> str:\n+ encoded = json.dumps(config, sort_keys=True, separators=(\",\", \":\")).encode()\n+ return hashlib.sha256(encoded).hexdigest()\n+\n+\n+def _ref_pattern_regex(pattern: str) -> re.Pattern:\n+ out = \"\"\n+ i = 0\n+ while i < len(pattern):\n+ if pattern[i:i + 2] == \"**\":\n+ out += \".*\"\n+ i += 2\n+ elif pattern[i] == \"*\":\n+ out += \"[^/]*\"\n+ i += 1\n+ elif pattern[i] == \"?\":\n+ out += \"[^/]\"\n+ i += 1\n+ else:\n+ out += re.escape(pattern[i])\n+ i += 1\n+ return re.compile(f\"^{out}$\")\n+\n+\n+def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None) -> bytes:\n+ command = [\n+ \"git\",\n+ \"--no-replace-objects\",\n+ \"-c\", \"core.quotepath=true\",\n+ \"-c\", \"core.attributesFile=/dev/null\",\n+ \"-c\", \"diff.external=\",\n+ \"-c\", \"diff.renames=false\",\n+ \"-c\", \"diff.algorithm=myers\",\n+ \"-c\", \"diff.context=3\",\n+ ]\n+ if repo is not None:\n+ command += [\"-C\", str(repo)]\n+ command += list(args)\n+ try:\n+ result = subprocess.run(\n+ command,\n+ input=input_bytes,\n+ stdout=subprocess.PIPE,\n+ stderr=subprocess.PIPE,\n+ env={\n+ **os.environ,\n+ \"GIT_CONFIG_NOSYSTEM\": \"1\",\n+ \"GIT_CONFIG_GLOBAL\": os.devnull,\n+ \"GIT_NO_REPLACE_OBJECTS\": \"1\",\n+ \"LC_ALL\": \"C\",\n+ \"TZ\": \"UTC\",\n+ },\n+ timeout=GIT_TIMEOUT_SECONDS,\n+ check=False,\n+ )\n+ except subprocess.TimeoutExpired as exc:\n+ raise RuntimeError(f\"git command timed out: {args[0]}\") from exc\n+ if result.returncode:\n+ error = result.stderr.decode(\"utf-8\", \"replace\").strip()\n+ raise RuntimeError(f\"git {args[0]} failed: {error}\")\n+ return result.stdout\n+\n+\n+def _ensure_mirror(repo: dict) -> pathlib.Path:\n+ GIT_MIRROR_DIR.mkdir(parents=True, exist_ok=True)\n+ mirror = GIT_MIRROR_DIR / f\"{repo['id']}.git\"\n+ if not mirror.exists():\n+ _git(None, \"clone\", \"--mirror\", \"--\", repo[\"url\"], str(mirror))\n+ else:\n+ actual_url = _git(mirror, \"remote\", \"get-url\", \"origin\").decode().strip()\n+ if actual_url != repo[\"url\"]:\n+ raise RuntimeError(\n+ f\"mirror URL mismatch for {repo['id']}: {actual_url!r}\"\n+ )\n+ _git(mirror, \"fetch\", \"--prune\", \"origin\", \"+refs/*:refs/*\")\n+ _git(mirror, \"fsck\", \"--connectivity-only\", \"--no-dangling\")\n+ return mirror\n+\n+\n+def _matching_refs(mirror: pathlib.Path, patterns: list[str]) -> list[dict]:\n+ regexes = [_ref_pattern_regex(p) for p in patterns]\n+ lines = _git(\n+ mirror, \"for-each-ref\", \"--format=%(refname)%00%(objectname)\"\n+ ).decode(\"utf-8\", \"replace\").splitlines()\n+ selected = []\n+ for line in lines:\n+ if not line:\n+ continue\n+ ref_name, direct_oid = line.split(\"\\x00\", 1)\n+ if not any(r.fullmatch(ref_name) for r in regexes):\n+ continue\n+ commit_oid = _git(\n+ mirror, \"rev-parse\", \"--verify\", f\"{ref_name}^{{commit}}\"\n+ ).decode().strip()\n+ selected.append({\n+ \"name\": ref_name,\n+ \"direct_oid\": direct_oid,\n+ \"commit_oid\": commit_oid,\n+ })\n+ if not selected:\n+ raise RuntimeError(f\"no refs matched patterns {patterns!r}\")\n+ return sorted(selected, key=lambda r: r[\"name\"])\n+\n+\n+def _commit_metadata(mirror: pathlib.Path, oid: str) -> dict:\n+ raw = _git(\n+ mirror,\n+ \"show\",\n+ \"-s\",\n+ \"--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B\",\n+ oid,\n+ ).decode(\"utf-8\", \"replace\")\n+ fields = raw.split(\"\\x00\", 9)\n+ if len(fields) != 10:\n+ raise RuntimeError(f\"could not parse commit metadata for {oid}\")\n+ return {\n+ \"oid\": fields[0],\n+ \"tree_oid\": fields[1],\n+ \"parent_oids\": fields[2].split() if fields[2] else [],\n+ \"author_name\": fields[3],\n+ \"author_email\": fields[4].strip().lower(),\n+ \"author_timestamp_ms\": int(fields[5]) * 1000,\n+ \"committer_name\": fields[6],\n+ \"committer_email\": fields[7].strip().lower(),\n+ \"committer_timestamp_ms\": int(fields[8]) * 1000,\n+ \"message\": fields[9].rstrip(\"\\n\"),\n+ }\n \n \n-def unified_diff_from_commit_payload(payload: dict) -> str:\n- parts = []\n- for f in payload.get(\"files\") or []:\n- name = f.get(\"filename\", \"?\")\n- patch = f.get(\"patch\")\n- if patch:\n- parts.append(f\"--- {name}\\n{patch}\")\n- else:\n- parts.append(f\"--- {name}\\n[no textual patch: binary, submodule, or too large]\\n\")\n- return \"\\n\\n\".join(parts) if parts else \"[no files in API response]\"\n+def _commit_patch(mirror: pathlib.Path, metadata: dict) -> tuple[str, str | None]:\n+ parents = metadata[\"parent_oids\"]\n+ if len(parents) > 1:\n+ return \"\", None\n+ if parents:\n+ args = (\"diff\", \"--patch\", \"--binary\", \"--full-index\", \"--no-renames\",\n+ \"--no-ext-diff\", \"--no-textconv\", \"--src-prefix=a/\",\n+ \"--dst-prefix=b/\", parents[0], metadata[\"oid\"], \"--\")\n+ else:\n+ args = (\"diff-tree\", \"--root\", \"--patch\", \"--binary\", \"--full-index\",\n+ \"--no-renames\", \"--no-ext-diff\", \"--no-textconv\",\n+ \"--src-prefix=a/\", \"--dst-prefix=b/\", \"--no-commit-id\",\n+ metadata[\"oid\"], \"--\")\n+ patch_bytes = _git(mirror, *args)\n+ if not patch_bytes.strip():\n+ return \"\", None\n+ # Run patch-id outside the repository so SHA-1 and SHA-256 repositories use\n+ # the same canonical patch hash algorithm.\n+ patch_id_out = _git(\n+ None, \"patch-id\", \"--stable\", input_bytes=patch_bytes\n+ ).decode().strip()\n+ if patch_id_out:\n+ stable_id = patch_id_out.split()[0]\n+ else:\n+ stable_id = hashlib.sha256(patch_bytes).hexdigest()\n+ return patch_bytes.decode(\"utf-8\", \"replace\"), f\"{PATCH_IDENTITY_VERSION}:{stable_id}\"\n+\n+\n+def _replayed_discovery_state(events: list) -> tuple[set[str], dict[str, str]]:\n+ seen_oids = set()\n+ seen_patches = {}\n+ for event_ in events:\n+ if not isinstance(event_, GitDiscovery):\n+ continue\n+ for observation in event_.observations:\n+ seen_oids.add(observation[\"oid\"])\n+ patch_identity = observation.get(\"patch_identity\")\n+ canonical_oid = observation.get(\"canonical_patch_oid\")\n+ if patch_identity and canonical_oid:\n+ seen_patches.setdefault(patch_identity, canonical_oid)\n+ return seen_oids, seen_patches\n+\n+\n+def _build_discovery(epoch_n: int, boundary_ms: int, events: list) -> GitDiscovery:\n+ config = _normalized_discovery_config()\n+ digest = _config_digest(config)\n+ prior_discoveries = [e for e in events if isinstance(e, GitDiscovery)]\n+ initial = not prior_discoveries\n+ seen_oids, seen_patches = _replayed_discovery_state(events)\n+ email_to_contributor = {\n+ email: contributor\n+ for contributor, emails in config[\"contributors\"].items()\n+ for email in emails\n+ }\n \n+ repository_rows = []\n+ locations: dict[str, list[tuple[str, str, pathlib.Path, str]]] = {}\n+ for repo in config[\"repositories\"]:\n+ mirror = _ensure_mirror(repo)\n+ object_format = _git(\n+ mirror, \"rev-parse\", \"--show-object-format\"\n+ ).decode().strip()\n+ refs = _matching_refs(mirror, repo[\"refs\"])\n+ repo_reachable = set()\n+ for ref in refs:\n+ oids = _git(mirror, \"rev-list\", ref[\"commit_oid\"]).decode().splitlines()\n+ for oid in oids:\n+ qualified = f\"{object_format}:{oid}\"\n+ repo_reachable.add(qualified)\n+ locations.setdefault(qualified, []).append(\n+ (repo[\"id\"], ref[\"name\"], mirror, oid)\n+ )\n+ repository_rows.append({\n+ \"id\": repo[\"id\"],\n+ \"url\": repo[\"url\"],\n+ \"object_format\": object_format,\n+ \"refs\": refs,\n+ \"reachable_commit_count\": len(repo_reachable),\n+ \"reachable_set_sha256\": hashlib.sha256(\n+ \"\\n\".join(sorted(repo_reachable)).encode()\n+ ).hexdigest(),\n+ })\n \n-async def fetch_commits_since(since_ms):\n- since_iso = datetime.fromtimestamp(since_ms / 1000, tz=timezone.utc).isoformat()\n- async with httpx.AsyncClient() as client:\n- resp = await client.get(\n- f\"{GITHUB_API_BASE_URL}/repos/{REPO}/commits\",\n- params={\"since\": since_iso, \"per_page\": 100},\n- headers=_github_headers(),\n+ new_oids = sorted(set(locations) - seen_oids)\n+ pending = []\n+ for qualified_oid in new_oids:\n+ source_rows = sorted({\n+ (repo_id, ref_name) for repo_id, ref_name, _, _ in locations[qualified_oid]\n+ })\n+ canonical_location = min(\n+ locations[qualified_oid], key=lambda x: (x[0], x[1])\n )\n- return resp.json()\n-\n+ object_hashes = {\n+ hashlib.sha256(_git(m, \"cat-file\", \"commit\", raw_oid)).hexdigest()\n+ for _, _, m, raw_oid in locations[qualified_oid]\n+ }\n+ if len(object_hashes) != 1:\n+ raise RuntimeError(f\"conflicting Git objects share OID {qualified_oid}\")\n+ _, _, mirror, oid = canonical_location\n+ metadata = _commit_metadata(mirror, oid)\n+ patch, patch_identity = _commit_patch(mirror, metadata)\n+ pending.append({\n+ **metadata,\n+ \"oid\": qualified_oid,\n+ \"commit_object_sha256\": next(iter(object_hashes)),\n+ \"tree_oid\": f\"{qualified_oid.split(':', 1)[0]}:{metadata['tree_oid']}\",\n+ \"parent_oids\": [\n+ f\"{qualified_oid.split(':', 1)[0]}:{p}\"\n+ for p in metadata[\"parent_oids\"]\n+ ],\n+ \"patch\": patch,\n+ \"patch_sha256\": hashlib.sha256(patch.encode()).hexdigest() if patch else None,\n+ \"patch_identity_version\": PATCH_IDENTITY_VERSION,\n+ \"patch_identity\": patch_identity,\n+ \"first_sources\": [\n+ {\"repository_id\": repo_id, \"ref_name\": ref_name}\n+ for repo_id, ref_name in source_rows\n+ ],\n+ \"contributor\": email_to_contributor.get(metadata[\"author_email\"]),\n+ })\n \n-async def fetch_commit_unified_diff(client: httpx.AsyncClient, sha: str) -> str:\n- resp = await client.get(\n- f\"{GITHUB_API_BASE_URL}/repos/{REPO}/commits/{sha}\",\n- headers=_github_headers(),\n+ # Select patch representatives independently of repository/ref iteration order.\n+ # Every observed patch consumes its identity, even when it predates genesis or\n+ # has no registered contributor: copying already-observed work later must not\n+ # turn it into a newly payable contribution.\n+ pending.sort(key=lambda c: (c[\"committer_timestamp_ms\"], c[\"oid\"]))\n+ observations = []\n+ commits = []\n+ for commit in pending:\n+ reason = None\n+ canonical_patch_oid = None\n+ patch_identity = commit[\"patch_identity\"]\n+ duplicate_patch = False\n+ if patch_identity:\n+ if patch_identity in seen_patches:\n+ canonical_patch_oid = seen_patches[patch_identity]\n+ duplicate_patch = True\n+ else:\n+ canonical_patch_oid = commit[\"oid\"]\n+ seen_patches[patch_identity] = canonical_patch_oid\n+\n+ if initial and commit[\"committer_timestamp_ms\"] < GENESIS_MS:\n+ reason = \"before_genesis\"\n+ elif len(commit[\"parent_oids\"]) > 1:\n+ reason = \"merge_commit\"\n+ elif not patch_identity:\n+ reason = \"empty_commit\"\n+ elif duplicate_patch:\n+ reason = \"duplicate_patch\"\n+ else:\n+ if commit[\"contributor\"] is None:\n+ reason = \"unknown_contributor\"\n+\n+ eligible = reason is None\n+ observation = {\n+ \"oid\": commit[\"oid\"],\n+ \"first_sources\": commit[\"first_sources\"],\n+ \"committer_timestamp_ms\": commit[\"committer_timestamp_ms\"],\n+ \"patch_identity\": patch_identity,\n+ \"canonical_patch_oid\": canonical_patch_oid,\n+ \"eligible\": eligible,\n+ \"exclusion_reason\": reason,\n+ }\n+ observations.append(observation)\n+ if eligible:\n+ commits.append(commit)\n+\n+ snapshot_material = {\n+ \"schema_version\": GIT_DISCOVERY_SCHEMA_VERSION,\n+ \"epoch\": epoch_n,\n+ \"timestamp_ms\": boundary_ms,\n+ \"config_digest\": digest,\n+ \"repositories\": repository_rows,\n+ \"observations\": observations,\n+ }\n+ snapshot_id = hashlib.sha256(\n+ json.dumps(snapshot_material, sort_keys=True, separators=(\",\", \":\")).encode()\n+ ).hexdigest()\n+ return GitDiscovery(\n+ schema_version=GIT_DISCOVERY_SCHEMA_VERSION,\n+ epoch=epoch_n,\n+ snapshot_id=snapshot_id,\n+ timestamp_ms=boundary_ms,\n+ config_digest=digest,\n+ initial_snapshot=initial,\n+ configuration=config,\n+ repositories=repository_rows,\n+ observations=observations,\n+ commits=commits,\n )\n- resp.raise_for_status()\n- return unified_diff_from_commit_payload(resp.json())\n+\n+\n+def _acquire_discovery_file_lock():\n+ GIT_MIRROR_DIR.mkdir(parents=True, exist_ok=True)\n+ lock_file = (GIT_MIRROR_DIR / \".discovery.lock\").open(\"a+b\")\n+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)\n+ return lock_file\n+\n+\n+def _release_discovery_file_lock(lock_file) -> None:\n+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)\n+ lock_file.close()\n+\n+\n+async def discover_repositories(epoch_n: int, boundary_ms: int) -> GitDiscovery:\n+ async with _DISCOVERY_LOCK:\n+ lock_file = await asyncio.to_thread(_acquire_discovery_file_lock)\n+ try:\n+ events = store.read()\n+ existing = next(\n+ (\n+ e for e in events\n+ if isinstance(e, GitDiscovery) and e.epoch == epoch_n\n+ ),\n+ None,\n+ )\n+ if existing:\n+ return existing\n+ candidate = await asyncio.to_thread(\n+ _build_discovery, epoch_n, boundary_ms, events\n+ )\n+\n+ def append_if_new(current_events):\n+ if any(\n+ isinstance(e, GitDiscovery) and e.epoch == epoch_n\n+ for e in current_events\n+ ):\n+ return None\n+ return candidate\n+\n+ appended = await store.atomic(append_if_new)\n+ if appended:\n+ return appended\n+ return next(\n+ e for e in store.read()\n+ if isinstance(e, GitDiscovery) and e.epoch == epoch_n\n+ )\n+ finally:\n+ await asyncio.to_thread(_release_discovery_file_lock, lock_file)\n \n \n SSE_CLIENTS = []\n@@ -581,28 +1002,27 @@ async def broadcast_js(js: str):\n await queue.put(js)\n \n \n-async def rank_commits(since_ms):\n- commits = await fetch_commits_since(since_ms)\n+async def rank_commits(commits: list[dict]):\n if not commits:\n- return {}\n-\n- async with httpx.AsyncClient() as gh:\n- commit_diffs = await asyncio.gather(*[fetch_commit_unified_diff(gh, c[\"sha\"]) for c in commits])\n+ return {}, []\n \n models = await fetch_top_models(n=3)\n+ contributors = sorted(set(c[\"contributor\"] for c in commits))\n+ if len(contributors) > 1 and not models:\n+ raise RuntimeError(\"no council models available for contributor ranking\")\n await broadcast_js(exec_event(Three[Selector(\"#emission-log\")][PREPEND][\n [\"div.log-council\", f\"Council: {', '.join(models)} — {len(commits)} commits\"]\n ]))\n \n- authors = list(set(c[\"commit\"][\"author\"][\"name\"] for c in commits))\n+ authors = contributors\n author_idx = {a: i for i, a in enumerate(authors)}\n \n author_commits = {a: [] for a in authors}\n- for c, diff_text in zip(commits, commit_diffs, strict=True):\n- author_commits[c[\"commit\"][\"author\"][\"name\"]].append({\n- \"message\": c[\"commit\"][\"message\"],\n- \"sha\": c[\"sha\"][:8],\n- \"diff\": diff_text,\n+ for c in sorted(commits, key=lambda row: row[\"oid\"]):\n+ author_commits[c[\"contributor\"]].append({\n+ \"message\": c[\"message\"],\n+ \"sha\": c[\"oid\"].split(\":\", 1)[1][:8],\n+ \"diff\": c[\"patch\"],\n })\n \n # TODO do we want ot coagulate the commits into a single block? or rank the many commits\n@@ -622,9 +1042,14 @@ async def rank_commits(since_ms):\n for model in models:\n try:\n result = await llm_pairwise_compare(model, author_side_for_llm(a1), author_side_for_llm(a2))\n+ if result[\"winner\"] not in {\"A\", \"B\"}:\n+ raise ValueError(\"winner must be A or B\")\n w, l = (i, j) if result[\"winner\"] == \"A\" else (j, i)\n ratio = result[\"ratio\"].split(\":\")\n- results.append((w, l, float(ratio[0]), float(ratio[1])))\n+ winner_weight, loser_weight = float(ratio[0]), float(ratio[1])\n+ if winner_weight <= 0 or loser_weight <= 0:\n+ raise ValueError(\"ratio weights must be positive\")\n+ results.append((w, l, winner_weight, loser_weight))\n await broadcast_js(exec_event(Three[Selector(\"#emission-log\")][PREPEND][\n [\"div.log-vote\",\n [\"span.model\", model], \" — \",\n@@ -637,6 +1062,7 @@ async def rank_commits(since_ms):\n await broadcast_js(exec_event(Three[Selector(\"#emission-log\")][PREPEND][\n [\"div.log-error\", f\"⚠ {model}: {e}\"]\n ]))\n+ raise RuntimeError(f\"council model failed: {model}\") from e\n return results\n \n async def progress_fn(ev):\n@@ -651,7 +1077,8 @@ async def rank_commits(since_ms):\n pairs = await pairwise_rank(len(authors), compare_fn, progress_fn)\n \n if not pairs:\n- return {authors[0]: Decimal(\"1\")} if authors else {}\n+ ranking = {authors[0]: Decimal(\"1\")} if authors else {}\n+ return ranking, models\n \n scores = rank_centrality(pairs)\n ranking = {authors[i]: Decimal(str(scores[i])) for i in range(len(authors))}\n@@ -662,7 +1089,7 @@ async def rank_commits(since_ms):\n *[[\"span.rank-entry\", f\"{a} {float(s):.3f} \"] for a, s in ranking_rows],\n ]\n ]))\n- return ranking\n+ return ranking, models\n \n \n # ===========================================================================\n@@ -681,35 +1108,48 @@ async def run_emission(epoch_n, boundary_ms):\n [\"div.log-start\", f\"⚡ Epoch {epoch_n} emission started\"]\n ]))\n \n- pool = pool_remaining(store.read())\n- emission = pool * DECAY_RATE\n- await broadcast_js(exec_event(Three[Selector(\"#emission-log\")][PREPEND][\n- [\"div.log-amount\",\n- f\"Pool {pool:.4f} → emit {emission:.4f} → {pool - emission:.4f}\"]\n- ]))\n-\n- prev_boundary_ms = epoch_boundary(epoch_n - 1) if epoch_n > 0 else GENESIS_MS\n- ranking = await rank_commits(prev_boundary_ms)\n+ discovery = await discover_repositories(epoch_n, boundary_ms)\n+ ranking, models = await rank_commits(discovery.commits)\n \n def make_emission(events):\n if epoch_n in {e.epoch for e in events if isinstance(e, Emission)}:\n return None\n pool_now = pool_remaining(events)\n- emission_now = pool_now * DECAY_RATE\n+ emission_now = pool_now * DECAY_RATE if ranking else Decimal(\"0\")\n+ normalized_ranking = {}\n+ distributions = {}\n+ if ranking:\n+ score_total = sum(ranking.values())\n+ normalized_ranking = {\n+ contributor: score / score_total\n+ for contributor, score in sorted(ranking.items())\n+ }\n+ contributors = list(normalized_ranking)\n+ allocated = Decimal(\"0\")\n+ for contributor in contributors[:-1]:\n+ amount = emission_now * normalized_ranking[contributor]\n+ distributions[contributor] = amount\n+ allocated += amount\n+ distributions[contributors[-1]] = emission_now - allocated\n return Emission(\n epoch=epoch_n,\n timestamp_ms=boundary_ms,\n+ discovery_snapshot_id=discovery.snapshot_id,\n pool_before=str(pool_now),\n total_emitted=str(emission_now),\n pool_after=str(pool_now - emission_now),\n decay_rate=str(DECAY_RATE),\n- distributions={a: str(emission_now * s) for a, s in ranking.items()},\n- ranking={a: str(s) for a, s in ranking.items()},\n- models_used=[],\n+ distributions={a: str(amount) for a, amount in distributions.items()},\n+ ranking={a: str(s) for a, s in normalized_ranking.items()},\n+ models_used=models,\n )\n \n entry = await store.atomic(make_emission)\n if entry:\n+ await broadcast_js(exec_event(Three[Selector(\"#emission-log\")][PREPEND][\n+ [\"div.log-amount\",\n+ f\"Pool {entry.pool_before} → emit {entry.total_emitted} → {entry.pool_after}\"]\n+ ]))\n await broadcast_js(exec_event(Three[Selector(\"#emission-log\")][PREPEND][\n [\"div.log-complete\",\n [\"b\", f\"✓ Epoch {entry.epoch} complete — emitted {entry.total_emitted} SLUG\"]]\n@@ -815,20 +1255,40 @@ async def get_contributor(github_username: str):\n \n @app.get(\"/api/halvening\")\n async def get_halvening():\n- half_life = sp.Rational(str(HALF_LIFE_YEARS))\n- boundary = genesis_ms\n- elapsed = sp.Integer(0)\n- epoch_years = sp.Rational(1, 12)\n- for e in range(300):\n- epoch_dur = tropical_epoch_ms(boundary)\n- if elapsed + epoch_years >= half_life:\n- fraction = (half_life - elapsed) / epoch_years\n- jubilee_ms = round_sympy_ms(boundary + fraction * epoch_dur)\n- dt = datetime.fromtimestamp(jubilee_ms / 1000, tz=timezone.utc)\n- return {\"jubilee_ms\": jubilee_ms, \"jubilee_utc\": dt.isoformat(),\n- \"epoch\": e + float(fraction), \"half_life_years\": str(HALF_LIFE_YEARS)}\n- elapsed += epoch_years\n- boundary += epoch_dur\n+ # Iterating symbolic rationals recursively causes expression-size explosion\n+ # after hundreds of epochs. Fifty-digit Decimal arithmetic is far beyond the\n+ # millisecond precision exposed by this endpoint and remains deterministic.\n+ boundary = Decimal(GENESIS_MS)\n+ j2000 = Decimal(946728000000)\n+ century = Decimal(36525 * 86400 * 1000)\n+ day = Decimal(86400000)\n+\n+ def decimal_epoch_ms(at_ms: Decimal) -> Decimal:\n+ T = (at_ms - j2000) / century\n+ days = (\n+ Decimal(\"365.2421896698\")\n+ + Decimal(\"-6.15359e-6\") * T\n+ + Decimal(\"-7.29e-10\") * T**2\n+ + Decimal(\"2.64e-10\") * T**3\n+ )\n+ return days * day / Decimal(12)\n+\n+ total_epochs = HALF_LIFE_YEARS * Decimal(12)\n+ whole_epochs = int(total_epochs)\n+ fraction = total_epochs - whole_epochs\n+ for _ in range(whole_epochs):\n+ boundary += decimal_epoch_ms(boundary)\n+ jubilee_ms = int(\n+ (boundary + fraction * decimal_epoch_ms(boundary))\n+ .to_integral_value(rounding=\"ROUND_HALF_UP\")\n+ )\n+ dt = datetime.fromtimestamp(jubilee_ms / 1000, tz=timezone.utc)\n+ return {\n+ \"jubilee_ms\": jubilee_ms,\n+ \"jubilee_utc\": dt.isoformat(),\n+ \"epoch\": float(total_epochs),\n+ \"half_life_years\": str(HALF_LIFE_YEARS),\n+ }\n \n \n @app.post(\"/test/emit\")\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 46f0b6fd922995045008a5e935c5178e9519ac7c..65092f570614a9a5473fea04150a9871d5e0490c 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -3,7 +3,7 @@ name = \"slug-constitution\"\n version = \"0.1.0\"\n requires-python = \">=3.11\"\n dependencies = [\n- \"evaleval>=0.2.6\",\n+ \"evaleval==0.2.7\",\n \"numpy\",\n \"httpx\",\n \"fastapi\",\n@@ -12,6 +12,10 @@ dependencies = [\n \"starlette\",\n \"itsdangerous\",\n \"pytest\",\n+ \"hypothesis\",\n+ \"sympy\",\n+ \"authlib\",\n+ \"python-multipart\",\n ]\n \n [tool.pytest.ini_options]\ndiff --git a/tests/integration.clj b/tests/integration.clj\nindex fdc0931e937bcf5f0e1bea8c5b8c632ec99eab17..0d34e3b237b4037a28f5e99350e8d447455665de 100644\n--- a/tests/integration.clj\n+++ b/tests/integration.clj\n@@ -1,7 +1,7 @@\n #!/usr/bin/env bb\n (ns test.integration\n \"Integration tests for constitution.py.\n- Starts the server, mocks OpenRouter + GitHub APIs, seeds a JSONL,\n+ Starts the server, mocks OpenRouter, creates local Git remotes, seeds a JSONL,\n verifies API endpoints, SSE stream, and replay determinism.\"\n (:require [babashka.process :as p]\n [clojure.string :as str]\n@@ -96,6 +96,47 @@\n (throw (ex-info \"bad HTTP status\" {:code code :body body})))\n (json/parse-string body true))))\n \n+(defn- command!\n+ [argv opts]\n+ (let [r @(p/process argv (merge {:out :string :err :string} opts))]\n+ (when-not (zero? (:exit r))\n+ (throw (ex-info (str \"command failed: \" (pr-str argv) \"\\n\" (:err r))\n+ {:argv argv :result r})))\n+ (str/trim (:out r))))\n+\n+(defn- git!\n+ [dir & args]\n+ (let [base-env (into {} (System/getenv))\n+ env (merge base-env\n+ {\"GIT_CONFIG_NOSYSTEM\" \"1\"\n+ \"GIT_AUTHOR_NAME\" \"Integration\"\n+ \"GIT_AUTHOR_EMAIL\" \"integration@example.test\"\n+ \"GIT_COMMITTER_NAME\" \"Integration\"\n+ \"GIT_COMMITTER_EMAIL\" \"integration@example.test\"})]\n+ (command! (into [\"git\" \"-C\" dir] args) {:env env})))\n+\n+(defn- make-git-repository!\n+ [tmp-dir id contributor email filename]\n+ (let [remote (str tmp-dir \"/\" id \".git\")\n+ work (str tmp-dir \"/\" id \"-work\")]\n+ (command! [\"git\" \"init\" \"--bare\" remote] {})\n+ (command! [\"git\" \"init\" \"-b\" \"main\" work] {})\n+ (git! work \"config\" \"user.name\" contributor)\n+ (git! work \"config\" \"user.email\" email)\n+ (spit (str work \"/\" filename) (str contributor \" contribution\\n\"))\n+ (git! work \"add\" \"--\" filename)\n+ (let [env (merge (into {} (System/getenv))\n+ {\"GIT_CONFIG_NOSYSTEM\" \"1\"\n+ \"GIT_AUTHOR_NAME\" contributor\n+ \"GIT_AUTHOR_EMAIL\" email\n+ \"GIT_COMMITTER_NAME\" contributor\n+ \"GIT_COMMITTER_EMAIL\" email})]\n+ (command! [\"git\" \"-C\" work \"commit\" \"-m\" (str \"contribution by \" contributor)]\n+ {:env env}))\n+ (git! work \"remote\" \"add\" \"origin\" remote)\n+ (git! work \"push\" \"origin\" \"main\")\n+ {:id id :url remote :refs [\"refs/heads/**\"]}))\n+\n ;; ---------------------------------------------------------------------------\n ;; mock OpenRouter server\n ;; ---------------------------------------------------------------------------\n@@ -199,6 +240,7 @@\n (let [entry {:type \"emission\"\n :epoch 0\n :timestamp_ms 1700000000000\n+ :discovery_snapshot_id \"seeded-discovery\"\n :pool_before \"175824\"\n :total_emitted \"572.1423838308\"\n :pool_after \"175251.857616169\"\n@@ -239,8 +281,8 @@\n (str/trim (second m)))))\n \n (defn- test-live-slug-model-council! [root]\n- (if (= \"1\" (System/getenv \"SKIP_LIVE_SLUG_SOCIAL\"))\n- (println \"\\n━━━ live slug.social checks skipped (SKIP_LIVE_SLUG_SOCIAL=1) ━━━\\n\")\n+ (if-not (= \"1\" (System/getenv \"RUN_LIVE_SLUG_SOCIAL\"))\n+ (println \"\\n━━━ live slug.social checks skipped (set RUN_LIVE_SLUG_SOCIAL=1) ━━━\\n\")\n (letlocals\n (println \"\\n━━━ live slug.social: /api/v0 + fetch_top_models ━━━\\n\")\n (bind rank-url (str slug-live-base \"/api/v0/rank?parent=\"\n@@ -307,7 +349,7 @@\n (when (str/starts-with? line \"data:\")\n (let [raw (str/trim (subs line 5))]\n (when-not (str/blank? raw)\n- (swap! events conj (json/parse-string raw true)))))\n+ (swap! events conj raw))))\n (recur))\n nil)))\n (finally\n@@ -329,7 +371,6 @@\n (bind jsonl-path (str tmp-dir \"/ledger.jsonl\"))\n (bind server-port (pick-port))\n (bind or-port (pick-port)) ; openrouter mock\n- (bind gh-port (pick-port)) ; github mock\n (bind base-url (str \"http://127.0.0.1:\" server-port))\n \n ;; genesis ~1 month ago so we're at epoch 1+\n@@ -337,38 +378,47 @@\n \n (bind root (project-root))\n (test-live-slug-model-council! root)\n+ (bind repo-a (make-git-repository! tmp-dir \"repo-a\" \"alice\"\n+ \"alice@example.test\" \"alice.txt\"))\n+ (bind repo-b (make-git-repository! tmp-dir \"repo-b\" \"bob\"\n+ \"bob@example.test\" \"bob.txt\"))\n+ (bind repositories-json\n+ (json/generate-string\n+ [(update repo-a :refs vec) (update repo-b :refs vec)]))\n+ (bind contributors-json\n+ (json/generate-string\n+ {\"alice\" [\"alice@example.test\"]\n+ \"bob\" [\"bob@example.test\"]}))\n (bind server-env\n {\"SESSION_SECRET\" \"test-secret\"\n \"GENESIS_MS\" genesis-ms\n \"JSONL_PATH\" jsonl-path\n+ \"GIT_MIRROR_DIR\" (str tmp-dir \"/mirrors\")\n+ \"REPOSITORIES_JSON\" repositories-json\n+ \"CONTRIBUTORS_JSON\" contributors-json\n \"OPENROUTER_API_KEY\" \"mock-key\"\n \"GITHUB_CLIENT_ID\" \"mock-gh-id\"\n \"GITHUB_CLIENT_SECRET\" \"mock-gh-secret\"\n- \"GITHUB_TOKEN\" \"mock-gh-token\"\n- \"REPO\" \"tommy-mor/slug\"\n \"PORT\" (str server-port)\n \"DISABLE_EPOCH_LOOP\" \"1\"\n \"ALLOW_TEST_TRIGGERS\" \"1\"\n \"OPENROUTER_BASE_URL\" (str \"http://127.0.0.1:\" or-port)\n- \"GITHUB_API_BASE_URL\" (str \"http://127.0.0.1:\" gh-port)\n \"SLUG_MODEL_RANK_PARENT\" \"\"\n \"PATH\" (get (into {} (System/getenv)) \"PATH\" \"\")})\n \n (bind !server (atom nil))\n (bind !server2 (atom nil))\n (bind !or-mock (atom nil))\n- (bind !gh-mock (atom nil))\n \n (try\n (letlocals\n- ;; 1. start mock servers\n- (println \"starting mock OpenRouter and GitHub API servers…\")\n+ ;; 1. start mock model server; Git discovery uses local bare remotes\n+ (println \"starting mock OpenRouter server and local Git remotes…\")\n (bind or-mock (start-mock-openrouter or-port))\n (reset! !or-mock or-mock)\n- (bind gh-mock (start-mock-github gh-port))\n- (reset! !gh-mock gh-mock)\n (assert! (some? (:stop-fn or-mock)) \"mock OpenRouter started\")\n- (assert! (some? (:stop-fn gh-mock)) \"mock GitHub started\")\n+ (assert! (fs/exists? (:url repo-a)) \"first bare Git remote exists\")\n+ (assert! (fs/exists? (:url repo-b)) \"second bare Git remote exists\")\n \n ;; 2. seed ledger so pool_remaining has history to read\n (seed-ledger jsonl-path)\n@@ -422,7 +472,8 @@\n (println \"\\nchecking /sse initial event…\")\n (bind sse-events (read-sse-events (str base-url \"/sse\") 1 5000))\n (assert! (= 1 (count sse-events)) \"received 1 SSE event\")\n- (assert! (int? (:epoch (first sse-events))) \"initial SSE event has epoch\")\n+ (assert! (not (str/blank? (first sse-events)))\n+ \"initial SSE event contains executable audit data\")\n \n ;; 10. POST /test/emit — full ranking pipeline hits mocks\n (println \"\\ntriggering /test/emit (epoch 1)…\")\n@@ -430,16 +481,25 @@\n (assert! (= \"emission\" (:type emit-resp)) \"emit response type is emission\")\n (assert! (= 1 (:epoch emit-resp)) \"emit is epoch 1 after seeded epoch 0\")\n (assert! (pos? (count (:distributions emit-resp))) \"emit has distributions\")\n+ (assert! (string? (:discovery_snapshot_id emit-resp))\n+ \"emission records discovery snapshot\")\n+ (assert! (= 3 (count (:models_used emit-resp)))\n+ \"emission records all council models\")\n \n (bind or-state @(:state or-mock))\n- (bind gh-state @(:state gh-mock))\n (assert! (pos? (:model-requests or-state)) \"OpenRouter /models was called\")\n (assert! (>= (:compare-requests or-state) 3) \"at least 3 pairwise LLM calls (2 authors × 3 models)\")\n- (assert! (pos? (:commit-list-requests gh-state)) \"GitHub commits list was called\")\n- (assert! (>= (:commit-detail-requests gh-state) 2) \"GitHub per-SHA fetches for each commit\")\n \n (bind ledger2 (get-json base-url \"/api/ledger\"))\n- (assert! (= 2 (count ledger2)) \"ledger has 2 entries after emit\")\n+ (assert! (= 3 (count ledger2))\n+ \"ledger has seed, discovery, and emission entries\")\n+ (bind discovery-entry (second ledger2))\n+ (assert! (= \"gitdiscovery\" (:type discovery-entry))\n+ \"discovery is persisted before emission\")\n+ (assert! (= 2 (count (:repositories discovery-entry)))\n+ \"discovery records both repositories\")\n+ (assert! (= 2 (count (:commits discovery-entry)))\n+ \"discovery admits one contribution from each repository\")\n (bind rank-after (get-json base-url \"/api/ranking\"))\n (assert! (= 1 (:epoch rank-after)) \"latest ranking is epoch 1\")\n \n@@ -458,7 +518,8 @@\n \"restarted server responds to /api/epoch\")\n \n (bind replayed-ledger (get-json base-url \"/api/ledger\"))\n- (assert! (= 2 (count replayed-ledger)) \"ledger still has 2 entries after replay\")\n+ (assert! (= 3 (count replayed-ledger))\n+ \"ledger still has 3 entries after replay\")\n (bind replayed-rank (get-json base-url \"/api/ranking\"))\n (assert! (= (get-in rank-after [:ranking :alice])\n (get-in replayed-rank [:ranking :alice]))\n@@ -473,7 +534,6 @@\n (.destroyForcibly (:proc s))\n (deref s))\n (when-some [m @!or-mock] ((:stop-fn m)))\n- (when-some [m @!gh-mock] ((:stop-fn m)))\n (fs/delete-tree tmp-dir)))\n \n (let [{:keys [pass fail]} @counts]\ndiff --git a/tests/test_git_discovery.py b/tests/test_git_discovery.py\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0dd31bc42a19bc8c59842dc61f193c595c474659\n--- /dev/null\n+++ b/tests/test_git_discovery.py\n@@ -0,0 +1,467 @@\n+\"\"\"Real-Git tests for the constitutional multi-repository discovery rules.\"\"\"\n+\n+from __future__ import annotations\n+\n+import asyncio\n+import hashlib\n+import os\n+import subprocess\n+from pathlib import Path\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+import constitution as c\n+\n+\n+def git(repo: Path | None, *args: str, env: dict | None = None) -> str:\n+ command = [\"git\"]\n+ if repo is not None:\n+ command += [\"-C\", str(repo)]\n+ command += list(args)\n+ merged_env = os.environ.copy()\n+ merged_env.update({\n+ \"GIT_CONFIG_NOSYSTEM\": \"1\",\n+ \"GIT_AUTHOR_NAME\": \"Test Author\",\n+ \"GIT_AUTHOR_EMAIL\": \"author@example.test\",\n+ \"GIT_COMMITTER_NAME\": \"Test Committer\",\n+ \"GIT_COMMITTER_EMAIL\": \"author@example.test\",\n+ })\n+ if env:\n+ merged_env.update(env)\n+ result = subprocess.run(\n+ command,\n+ env=merged_env,\n+ text=True,\n+ input=\"\",\n+ stdout=subprocess.PIPE,\n+ stderr=subprocess.PIPE,\n+ check=False,\n+ )\n+ assert result.returncode == 0, result.stderr\n+ return result.stdout.strip()\n+\n+\n+def make_remote(tmp_path: Path, name: str) -> tuple[Path, Path]:\n+ remote = tmp_path / f\"{name}.git\"\n+ work = tmp_path / f\"{name}-work\"\n+ git(None, \"init\", \"--bare\", str(remote))\n+ git(None, \"init\", \"-b\", \"main\", str(work))\n+ git(work, \"config\", \"user.name\", \"Test Author\")\n+ git(work, \"config\", \"user.email\", \"author@example.test\")\n+ git(work, \"remote\", \"add\", \"origin\", str(remote))\n+ return remote, work\n+\n+\n+def commit_file(\n+ work: Path,\n+ name: str,\n+ content: str | bytes,\n+ message: str,\n+ timestamp: int,\n+ *,\n+ email: str = \"author@example.test\",\n+) -> str:\n+ path = work / name\n+ path.parent.mkdir(parents=True, exist_ok=True)\n+ if isinstance(content, bytes):\n+ path.write_bytes(content)\n+ else:\n+ path.write_text(content)\n+ git(work, \"add\", \"--\", name)\n+ date = f\"@{timestamp} +0000\"\n+ return git(\n+ work,\n+ \"commit\",\n+ \"-m\",\n+ message,\n+ env={\n+ \"GIT_AUTHOR_EMAIL\": email,\n+ \"GIT_COMMITTER_EMAIL\": email,\n+ \"GIT_AUTHOR_DATE\": date,\n+ \"GIT_COMMITTER_DATE\": date,\n+ },\n+ ) and git(work, \"rev-parse\", \"HEAD\")\n+\n+\n+def push(work: Path, *refs: str) -> None:\n+ git(work, \"push\", \"--force\", \"origin\", *refs)\n+\n+\n+@pytest.fixture\n+def discovery_config(tmp_path, monkeypatch):\n+ monkeypatch.setattr(c, \"GIT_MIRROR_DIR\", tmp_path / \"mirrors\")\n+ monkeypatch.setattr(c, \"GENESIS_MS\", 1_000_000)\n+ monkeypatch.setattr(c, \"CONTRIBUTORS\", {\n+ \"alice\": [\"author@example.test\"],\n+ \"bob\": [\"bob@example.test\"],\n+ })\n+ return tmp_path\n+\n+\n+def configure(monkeypatch, repositories):\n+ monkeypatch.setattr(c, \"REPOSITORIES\", repositories)\n+\n+\n+def test_ref_patterns_distinguish_nested_branches():\n+ shallow = c._ref_pattern_regex(\"refs/heads/*\")\n+ recursive = c._ref_pattern_regex(\"refs/heads/**\")\n+ assert shallow.fullmatch(\"refs/heads/main\")\n+ assert not shallow.fullmatch(\"refs/heads/team/topic\")\n+ assert recursive.fullmatch(\"refs/heads/team/topic\")\n+\n+\n+def test_manifest_order_does_not_change_digest(monkeypatch):\n+ repos = [\n+ {\"id\": \"b\", \"url\": \"/b\", \"refs\": [\"refs/heads/z\", \"refs/heads/a\"]},\n+ {\"id\": \"a\", \"url\": \"/a\", \"refs\": [\"refs/heads/**\"]},\n+ ]\n+ configure(monkeypatch, repos)\n+ monkeypatch.setattr(c, \"CONTRIBUTORS\", {\"alice\": [\"A@EXAMPLE.TEST\"]})\n+ first = c._config_digest(c._normalized_discovery_config())\n+ configure(monkeypatch, list(reversed(repos)))\n+ second = c._config_digest(c._normalized_discovery_config())\n+ assert first == second\n+\n+\n+@pytest.mark.parametrize(\n+ \"repositories\",\n+ [\n+ [{\"id\": \"../escape\", \"url\": \"/x\", \"refs\": [\"refs/heads/main\"]}],\n+ [{\"id\": \"x\", \"url\": \"\", \"refs\": [\"refs/heads/main\"]}],\n+ [{\"id\": \"x\", \"url\": \"/x\", \"refs\": []}],\n+ [\n+ {\"id\": \"x\", \"url\": \"/x\", \"refs\": [\"refs/heads/main\"]},\n+ {\"id\": \"x\", \"url\": \"/y\", \"refs\": [\"refs/heads/main\"]},\n+ ],\n+ ],\n+)\n+def test_invalid_manifests_fail(repositories, monkeypatch):\n+ configure(monkeypatch, repositories)\n+ monkeypatch.setattr(c, \"CONTRIBUTORS\", {\"alice\": [\"a@example.test\"]})\n+ with pytest.raises(ValueError):\n+ c._normalized_discovery_config()\n+\n+\n+def test_bootstrap_genesis_nested_refs_and_exclusions(discovery_config, monkeypatch):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ old_oid = commit_file(work, \"old.txt\", \"old\", \"before genesis\", 999)\n+ new_oid = commit_file(work, \"new.txt\", \"new\", \"after genesis\", 1001)\n+ git(work, \"branch\", \"team/topic\")\n+ push(work, \"main\", \"team/topic\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/**\"],\n+ }])\n+\n+ event = c._build_discovery(0, c.GENESIS_MS, [])\n+ observations = {row[\"oid\"].split(\":\", 1)[1]: row for row in event.observations}\n+ assert observations[old_oid][\"exclusion_reason\"] == \"before_genesis\"\n+ assert observations[new_oid][\"eligible\"]\n+ assert {source[\"ref_name\"] for source in observations[new_oid][\"first_sources\"]} == {\n+ \"refs/heads/main\", \"refs/heads/team/topic\",\n+ }\n+ assert event.commits[0][\"contributor\"] == \"alice\"\n+\n+\n+def test_first_reachable_ignores_old_timestamp_after_bootstrap(\n+ discovery_config, monkeypatch\n+):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ commit_file(work, \"base.txt\", \"base\", \"base\", 1001)\n+ push(work, \"main\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"],\n+ }])\n+ first = c._build_discovery(0, c.GENESIS_MS, [])\n+\n+ git(work, \"checkout\", \"-b\", \"hidden\")\n+ old_dated_oid = commit_file(work, \"late.txt\", \"late\", \"old dated\", 500)\n+ git(work, \"checkout\", \"main\")\n+ git(work, \"merge\", \"--ff-only\", \"hidden\")\n+ push(work, \"main\")\n+\n+ second = c._build_discovery(1, c.GENESIS_MS + 1, [first])\n+ row = next(\n+ row for row in second.observations\n+ if row[\"oid\"].endswith(old_dated_oid)\n+ )\n+ assert row[\"eligible\"]\n+\n+\n+def test_force_push_removal_and_reintroduction_never_reattributes(\n+ discovery_config, monkeypatch\n+):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ base = commit_file(work, \"base.txt\", \"base\", \"base\", 1001)\n+ extra = commit_file(work, \"extra.txt\", \"extra\", \"extra\", 1002)\n+ push(work, \"main\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"],\n+ }])\n+ first = c._build_discovery(0, c.GENESIS_MS, [])\n+\n+ git(work, \"reset\", \"--hard\", base)\n+ push(work, \"main\")\n+ second = c._build_discovery(1, c.GENESIS_MS + 1, [first])\n+ assert second.observations == []\n+\n+ git(work, \"reset\", \"--hard\", extra)\n+ push(work, \"main\")\n+ third = c._build_discovery(2, c.GENESIS_MS + 2, [first, second])\n+ assert third.observations == []\n+\n+\n+def test_global_oid_and_cherry_pick_patch_dedup(discovery_config, monkeypatch):\n+ remote_a, work_a = make_remote(discovery_config, \"a\")\n+ commit_file(work_a, \"base.txt\", \"base\", \"base\", 1001)\n+ shared = commit_file(work_a, \"feature.txt\", \"feature\\n\", \"feature\", 1002)\n+ push(work_a, \"main\")\n+\n+ remote_b = discovery_config / \"b.git\"\n+ git(None, \"clone\", \"--bare\", str(remote_a), str(remote_b))\n+ work_b = discovery_config / \"b-work\"\n+ git(None, \"clone\", str(remote_b), str(work_b))\n+ git(work_b, \"config\", \"user.name\", \"Bob\")\n+ git(work_b, \"config\", \"user.email\", \"bob@example.test\")\n+\n+ configure(monkeypatch, [\n+ {\"id\": \"a\", \"url\": str(remote_a), \"refs\": [\"refs/heads/main\"]},\n+ {\"id\": \"b\", \"url\": str(remote_b), \"refs\": [\"refs/heads/main\"]},\n+ ])\n+ first = c._build_discovery(0, c.GENESIS_MS, [])\n+ shared_rows = [r for r in first.observations if r[\"oid\"].endswith(shared)]\n+ assert len(shared_rows) == 1\n+ assert len(shared_rows[0][\"first_sources\"]) == 2\n+\n+ git(work_b, \"checkout\", \"-b\", \"copy\", f\"{shared}^\")\n+ git(\n+ work_b,\n+ \"cherry-pick\",\n+ shared,\n+ env={\n+ \"GIT_COMMITTER_NAME\": \"Bob\",\n+ \"GIT_COMMITTER_EMAIL\": \"bob@example.test\",\n+ \"GIT_COMMITTER_DATE\": \"@1003 +0000\",\n+ },\n+ )\n+ copied = git(work_b, \"rev-parse\", \"HEAD\")\n+ git(work_b, \"branch\", \"-f\", \"main\", copied)\n+ push(work_b, \"main\")\n+\n+ second = c._build_discovery(1, c.GENESIS_MS + 1, [first])\n+ copied_row = next(r for r in second.observations if r[\"oid\"].endswith(copied))\n+ assert copied_row[\"exclusion_reason\"] == \"duplicate_patch\"\n+ assert copied_row[\"canonical_patch_oid\"].endswith(shared)\n+ assert second.commits == []\n+\n+\n+def test_merge_empty_unknown_and_binary_are_explicit(discovery_config, monkeypatch):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ commit_file(work, \"base.txt\", \"base\", \"base\", 1001)\n+ git(work, \"checkout\", \"-b\", \"feature\")\n+ commit_file(work, \"binary.bin\", b\"\\x00\\x01\\xff\", \"binary\", 1002)\n+ git(work, \"checkout\", \"main\")\n+ commit_file(work, \"main.txt\", \"main\", \"main\", 1003)\n+ git(\n+ work,\n+ \"merge\",\n+ \"--no-ff\",\n+ \"feature\",\n+ \"-m\",\n+ \"merge\",\n+ env={\"GIT_AUTHOR_DATE\": \"@1004 +0000\", \"GIT_COMMITTER_DATE\": \"@1004 +0000\"},\n+ )\n+ git(\n+ work,\n+ \"commit\",\n+ \"--allow-empty\",\n+ \"-m\",\n+ \"empty\",\n+ env={\"GIT_AUTHOR_DATE\": \"@1005 +0000\", \"GIT_COMMITTER_DATE\": \"@1005 +0000\"},\n+ )\n+ commit_file(\n+ work, \"unknown.txt\", \"unknown\", \"unknown\", 1006,\n+ email=\"unknown@example.test\",\n+ )\n+ push(work, \"main\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"],\n+ }])\n+\n+ event = c._build_discovery(0, c.GENESIS_MS, [])\n+ reasons = {row[\"exclusion_reason\"] for row in event.observations}\n+ assert {\"merge_commit\", \"empty_commit\", \"unknown_contributor\"} <= reasons\n+ binary = next(row for row in event.commits if row[\"message\"] == \"binary\")\n+ assert binary[\"patch_identity\"]\n+ assert binary[\"patch_sha256\"] == hashlib.sha256(\n+ binary[\"patch\"].encode()\n+ ).hexdigest()\n+\n+\n+def test_repository_failure_does_not_return_partial_snapshot(\n+ discovery_config, monkeypatch\n+):\n+ remote, work = make_remote(discovery_config, \"good\")\n+ commit_file(work, \"file.txt\", \"ok\", \"ok\", 1001)\n+ push(work, \"main\")\n+ configure(monkeypatch, [\n+ {\"id\": \"good\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"]},\n+ {\n+ \"id\": \"missing\",\n+ \"url\": str(discovery_config / \"missing.git\"),\n+ \"refs\": [\"refs/heads/main\"],\n+ },\n+ ])\n+ with pytest.raises(RuntimeError):\n+ c._build_discovery(0, c.GENESIS_MS, [])\n+\n+\n+def test_git_replace_refs_cannot_falsify_discovered_commit(\n+ discovery_config, monkeypatch\n+):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ original = commit_file(work, \"file.txt\", \"original\", \"original\", 1001)\n+ replacement = commit_file(work, \"file.txt\", \"replacement\", \"replacement\", 1002)\n+ git(work, \"replace\", original, replacement)\n+ git(work, \"reset\", \"--hard\", original)\n+ push(work, \"main\", f\"refs/replace/{original}\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"],\n+ }])\n+\n+ event = c._build_discovery(0, c.GENESIS_MS, [])\n+ discovered = next(row for row in event.commits if row[\"oid\"].endswith(original))\n+ assert discovered[\"message\"] == \"original\"\n+ assert \"original\" in discovered[\"patch\"]\n+ assert \"replacement\" not in discovered[\"patch\"]\n+\n+\n+def test_replay_is_idempotent_and_path_independent(discovery_config, monkeypatch):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ commit_file(work, \"file.txt\", \"ok\", \"ok\", 1001)\n+ push(work, \"main\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"],\n+ }])\n+ first = c._build_discovery(0, c.GENESIS_MS, [])\n+ second = c._build_discovery(1, c.GENESIS_MS + 1, [first])\n+ assert second.observations == []\n+ assert second.commits == []\n+ assert first.repositories[0][\"reachable_set_sha256\"] == (\n+ second.repositories[0][\"reachable_set_sha256\"]\n+ )\n+\n+\n+def test_git_timeout_is_reported_without_partial_result(monkeypatch, tmp_path):\n+ def timeout(*_args, **_kwargs):\n+ raise subprocess.TimeoutExpired([\"git\", \"fetch\"], 1)\n+\n+ monkeypatch.setattr(c.subprocess, \"run\", timeout)\n+ with pytest.raises(RuntimeError, match=\"timed out\"):\n+ c._git(tmp_path, \"fetch\")\n+\n+\n+def test_concurrent_same_epoch_discovery_appends_once(\n+ discovery_config, monkeypatch\n+):\n+ remote, work = make_remote(discovery_config, \"one\")\n+ commit_file(work, \"file.txt\", \"ok\", \"ok\", 1001)\n+ push(work, \"main\")\n+ configure(monkeypatch, [{\n+ \"id\": \"one\", \"url\": str(remote), \"refs\": [\"refs/heads/main\"],\n+ }])\n+ ledger_path = discovery_config / \"ledger.jsonl\"\n+ monkeypatch.setattr(c, \"store\", c.JsonlStore(ledger_path))\n+\n+ async def run_both():\n+ return await asyncio.gather(\n+ c.discover_repositories(0, c.GENESIS_MS),\n+ c.discover_repositories(0, c.GENESIS_MS),\n+ )\n+\n+ left, right = asyncio.run(run_both())\n+ assert left.snapshot_id == right.snapshot_id\n+ discoveries = [\n+ event for event in c.store.read() if isinstance(event, c.GitDiscovery)\n+ ]\n+ assert len(discoveries) == 1\n+\n+\n+def test_empty_epoch_records_zero_emission_without_burning_pool(\n+ discovery_config, monkeypatch\n+):\n+ monkeypatch.setattr(c, \"store\", c.JsonlStore(discovery_config / \"ledger.jsonl\"))\n+\n+ async def discover(_epoch, _boundary):\n+ return SimpleNamespace(commits=[], snapshot_id=\"empty-snapshot\")\n+\n+ async def rank(_commits):\n+ return {}, []\n+\n+ monkeypatch.setattr(c, \"discover_repositories\", discover)\n+ monkeypatch.setattr(c, \"rank_commits\", rank)\n+ entry = asyncio.run(c.run_emission(0, c.GENESIS_MS))\n+ assert c.Decimal(entry.total_emitted) == 0\n+ assert entry.pool_before == entry.pool_after\n+ assert entry.distributions == {}\n+\n+\n+def test_emission_distribution_sums_exactly_to_total(\n+ discovery_config, monkeypatch\n+):\n+ monkeypatch.setattr(c, \"store\", c.JsonlStore(discovery_config / \"ledger.jsonl\"))\n+\n+ async def discover(_epoch, _boundary):\n+ return SimpleNamespace(commits=[{\"x\": 1}], snapshot_id=\"ranked-snapshot\")\n+\n+ async def rank(_commits):\n+ return {\n+ \"alice\": c.Decimal(\"0.33333333333333333333333333333333333333333333333333\"),\n+ \"bob\": c.Decimal(\"0.66666666666666666666666666666666666666666666666667\"),\n+ }, [\"model\"]\n+\n+ monkeypatch.setattr(c, \"discover_repositories\", discover)\n+ monkeypatch.setattr(c, \"rank_commits\", rank)\n+ entry = asyncio.run(c.run_emission(0, c.GENESIS_MS))\n+ distributed = sum(c.Decimal(x) for x in entry.distributions.values())\n+ assert distributed == c.Decimal(entry.total_emitted)\n+ assert entry.discovery_snapshot_id == \"ranked-snapshot\"\n+\n+\n+def test_single_contributor_ranking_is_total_and_uses_no_pairwise_votes(\n+ monkeypatch,\n+):\n+ async def models(n=3):\n+ return []\n+\n+ monkeypatch.setattr(c, \"fetch_top_models\", models)\n+ ranking, used = asyncio.run(c.rank_commits([{\n+ \"contributor\": \"alice\",\n+ \"oid\": \"sha1:\" + \"a\" * 40,\n+ \"message\": \"one contribution\",\n+ \"patch\": \"patch\",\n+ }]))\n+ assert ranking == {\"alice\": c.Decimal(\"1\")}\n+ assert used == []\n+\n+\n+def test_any_council_failure_aborts_ranking(monkeypatch):\n+ async def models(n=3):\n+ return [\"broken\"]\n+\n+ async def compare(*_args):\n+ raise RuntimeError(\"model unavailable\")\n+\n+ monkeypatch.setattr(c, \"fetch_top_models\", models)\n+ monkeypatch.setattr(c, \"llm_pairwise_compare\", compare)\n+ commits = [\n+ {\n+ \"contributor\": contributor,\n+ \"oid\": \"sha1:\" + char * 40,\n+ \"message\": contributor,\n+ \"patch\": \"patch\",\n+ }\n+ for contributor, char in [(\"alice\", \"a\"), (\"bob\", \"b\")]\n+ ]\n+ with pytest.raises(RuntimeError, match=\"council model failed\"):\n+ asyncio.run(c.rank_commits(commits))\ndiff --git a/tests/test_git_discovery_stateful.py b/tests/test_git_discovery_stateful.py\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e41041fdbc84fff441c85422c23eaa3df53b094b\n--- /dev/null\n+++ b/tests/test_git_discovery_stateful.py\n@@ -0,0 +1,161 @@\n+\"\"\"State-machine checks for discovery under changing real Git refs.\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import shutil\n+import subprocess\n+import tempfile\n+from pathlib import Path\n+\n+from hypothesis import settings\n+from hypothesis.stateful import (\n+ RuleBasedStateMachine,\n+ invariant,\n+ precondition,\n+ rule,\n+)\n+\n+import constitution as c\n+\n+\n+def git(repo: Path | None, *args: str, timestamp: int | None = None) -> str:\n+ command = [\"git\"]\n+ if repo is not None:\n+ command += [\"-C\", str(repo)]\n+ command += list(args)\n+ env = os.environ.copy()\n+ env.update({\n+ \"GIT_CONFIG_NOSYSTEM\": \"1\",\n+ \"GIT_AUTHOR_NAME\": \"State Machine\",\n+ \"GIT_AUTHOR_EMAIL\": \"state@example.test\",\n+ \"GIT_COMMITTER_NAME\": \"State Machine\",\n+ \"GIT_COMMITTER_EMAIL\": \"state@example.test\",\n+ })\n+ if timestamp is not None:\n+ env[\"GIT_AUTHOR_DATE\"] = f\"@{timestamp} +0000\"\n+ env[\"GIT_COMMITTER_DATE\"] = f\"@{timestamp} +0000\"\n+ result = subprocess.run(\n+ command,\n+ text=True,\n+ stdout=subprocess.PIPE,\n+ stderr=subprocess.PIPE,\n+ env=env,\n+ check=False,\n+ )\n+ if result.returncode:\n+ raise AssertionError(result.stderr)\n+ return result.stdout.strip()\n+\n+\n+class GitDiscoveryMachine(RuleBasedStateMachine):\n+ def __init__(self):\n+ super().__init__()\n+ self.root = Path(tempfile.mkdtemp(prefix=\"constitution-stateful-\"))\n+ self.remote = self.root / \"remote.git\"\n+ self.work = self.root / \"work\"\n+ git(None, \"init\", \"--bare\", str(self.remote))\n+ git(None, \"init\", \"-b\", \"main\", str(self.work))\n+ git(self.work, \"config\", \"user.name\", \"State Machine\")\n+ git(self.work, \"config\", \"user.email\", \"state@example.test\")\n+ git(self.work, \"remote\", \"add\", \"origin\", str(self.remote))\n+\n+ self.original = (\n+ c.REPOSITORIES, c.CONTRIBUTORS, c.GIT_MIRROR_DIR, c.GENESIS_MS,\n+ )\n+ c.REPOSITORIES = [{\n+ \"id\": \"state\",\n+ \"url\": str(self.remote),\n+ \"refs\": [\"refs/heads/main\"],\n+ }]\n+ c.CONTRIBUTORS = {\"state\": [\"state@example.test\"]}\n+ c.GIT_MIRROR_DIR = self.root / \"mirrors\"\n+ c.GENESIS_MS = 1_000_000\n+\n+ self.counter = 0\n+ self.events = []\n+ self.removed_tip: str | None = None\n+ self._commit_and_push()\n+\n+ def teardown(self):\n+ (\n+ c.REPOSITORIES, c.CONTRIBUTORS, c.GIT_MIRROR_DIR, c.GENESIS_MS,\n+ ) = self.original\n+ shutil.rmtree(self.root, ignore_errors=True)\n+\n+ def _commit_and_push(self):\n+ self.counter += 1\n+ path = self.work / f\"file-{self.counter}.txt\"\n+ path.write_text(f\"value {self.counter}\\n\")\n+ git(self.work, \"add\", \"--\", path.name)\n+ git(\n+ self.work,\n+ \"commit\",\n+ \"-m\",\n+ f\"commit {self.counter}\",\n+ timestamp=1000 + self.counter,\n+ )\n+ git(self.work, \"push\", \"--force\", \"origin\", \"main\")\n+\n+ @rule()\n+ def advance_branch(self):\n+ if self.removed_tip is None:\n+ self._commit_and_push()\n+\n+ @rule()\n+ def scan(self):\n+ event = c._build_discovery(\n+ len(self.events), c.GENESIS_MS + len(self.events), self.events\n+ )\n+ self.events.append(event)\n+\n+ @precondition(lambda self: self.removed_tip is None)\n+ @rule()\n+ def force_push_back(self):\n+ if self.counter < 2:\n+ return\n+ self.removed_tip = git(self.work, \"rev-parse\", \"HEAD\")\n+ git(self.work, \"reset\", \"--hard\", \"HEAD^\")\n+ git(self.work, \"push\", \"--force\", \"origin\", \"main\")\n+\n+ @precondition(lambda self: self.removed_tip is not None)\n+ @rule()\n+ def restore_force_pushed_tip(self):\n+ git(self.work, \"reset\", \"--hard\", self.removed_tip)\n+ git(self.work, \"push\", \"--force\", \"origin\", \"main\")\n+ self.removed_tip = None\n+\n+ @invariant()\n+ def observations_are_monotonic_and_unique(self):\n+ observed = [\n+ row[\"oid\"]\n+ for event in self.events\n+ for row in event.observations\n+ ]\n+ assert len(observed) == len(set(observed))\n+\n+ @invariant()\n+ def patch_classes_are_ranked_at_most_once(self):\n+ patch_ids = [\n+ commit[\"patch_identity\"]\n+ for event in self.events\n+ for commit in event.commits\n+ ]\n+ assert len(patch_ids) == len(set(patch_ids))\n+\n+ @invariant()\n+ def replay_matches_accumulated_state(self):\n+ seen_oids, seen_patches = c._replayed_discovery_state(self.events)\n+ expected_oids = {\n+ row[\"oid\"] for event in self.events for row in event.observations\n+ }\n+ assert seen_oids == expected_oids\n+ assert all(oid in seen_oids for oid in seen_patches.values())\n+\n+\n+TestGitDiscoveryStateMachine = GitDiscoveryMachine.TestCase\n+TestGitDiscoveryStateMachine.settings = settings(\n+ max_examples=8,\n+ stateful_step_count=12,\n+ deadline=None,\n+)\ndiff --git a/uv.lock b/uv.lock\nindex 9cf3e067adf2f39d9eb160631c3a4b3c237ec26a..bab8e115a73870fd236bd46a1bf773b44fb5d3ee 100644\n--- a/uv.lock\n+++ b/uv.lock\n@@ -33,6 +33,19 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl\", hash = \"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708\", size = 114353, upload-time = \"2026-03-24T12:59:08.246Z\" },\n ]\n \n+[[package]]\n+name = \"authlib\"\n+version = \"1.7.2\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+ { name = \"cryptography\" },\n+ { name = \"joserfc\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz\", hash = \"sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231\", size = 176511, upload-time = \"2026-05-06T08:10:23.116Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl\", hash = \"sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f\", size = 259548, upload-time = \"2026-05-06T08:10:21.436Z\" },\n+]\n+\n [[package]]\n name = \"certifi\"\n version = \"2026.2.25\"\n@@ -42,6 +55,104 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl\", hash = \"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa\", size = 153684, upload-time = \"2026-02-25T02:54:15.766Z\" },\n ]\n \n+[[package]]\n+name = \"cffi\"\n+version = \"2.1.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+ { name = \"pycparser\", marker = \"implementation_name != 'PyPy'\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz\", hash = \"sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9\", size = 531036, upload-time = \"2026-07-06T21:34:30.382Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl\", hash = \"sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc\", size = 183845, upload-time = \"2026-07-06T21:32:26.32Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl\", hash = \"sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7\", size = 184186, upload-time = \"2026-07-06T21:32:28.025Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl\", hash = \"sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93\", size = 211892, upload-time = \"2026-07-06T21:32:29.565Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2\", size = 218793, upload-time = \"2026-07-06T21:32:30.951Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c\", size = 205737, upload-time = \"2026-07-06T21:32:32.216Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f\", size = 204909, upload-time = \"2026-07-06T21:32:33.655Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565\", size = 217883, upload-time = \"2026-07-06T21:32:35.173Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl\", hash = \"sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c\", size = 221251, upload-time = \"2026-07-06T21:32:36.527Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl\", hash = \"sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02\", size = 214250, upload-time = \"2026-07-06T21:32:37.852Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl\", hash = \"sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e\", size = 219441, upload-time = \"2026-07-06T21:32:39.146Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl\", hash = \"sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479\", size = 174496, upload-time = \"2026-07-06T21:32:40.467Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl\", hash = \"sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458\", size = 185113, upload-time = \"2026-07-06T21:32:41.761Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl\", hash = \"sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d\", size = 179927, upload-time = \"2026-07-06T21:32:42.961Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl\", hash = \"sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f\", size = 184829, upload-time = \"2026-07-06T21:32:44.324Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde\", size = 184728, upload-time = \"2026-07-06T21:32:45.683Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl\", hash = \"sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d\", size = 214815, upload-time = \"2026-07-06T21:32:48.557Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7\", size = 222429, upload-time = \"2026-07-06T21:32:49.848Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b\", size = 210315, upload-time = \"2026-07-06T21:32:51.221Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7\", size = 208859, upload-time = \"2026-07-06T21:32:52.512Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66\", size = 221844, upload-time = \"2026-07-06T21:32:53.704Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl\", hash = \"sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe\", size = 225287, upload-time = \"2026-07-06T21:32:54.907Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl\", hash = \"sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b\", size = 223681, upload-time = \"2026-07-06T21:32:56.329Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl\", hash = \"sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a\", size = 175269, upload-time = \"2026-07-06T21:32:57.777Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl\", hash = \"sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384\", size = 185881, upload-time = \"2026-07-06T21:32:59.253Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl\", hash = \"sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6\", size = 180088, upload-time = \"2026-07-06T21:33:02.278Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl\", hash = \"sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda\", size = 194331, upload-time = \"2026-07-06T21:33:03.697Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl\", hash = \"sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b\", size = 196966, upload-time = \"2026-07-06T21:33:05.353Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl\", hash = \"sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a\", size = 184795, upload-time = \"2026-07-06T21:33:06.699Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl\", hash = \"sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea\", size = 184746, upload-time = \"2026-07-06T21:33:08.071Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl\", hash = \"sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db\", size = 214747, upload-time = \"2026-07-06T21:33:09.671Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f\", size = 222392, upload-time = \"2026-07-06T21:33:10.896Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d\", size = 210285, upload-time = \"2026-07-06T21:33:12.251Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0\", size = 208801, upload-time = \"2026-07-06T21:33:13.617Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224\", size = 221808, upload-time = \"2026-07-06T21:33:15.466Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl\", hash = \"sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c\", size = 225241, upload-time = \"2026-07-06T21:33:16.869Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl\", hash = \"sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a\", size = 223588, upload-time = \"2026-07-06T21:33:18.239Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl\", hash = \"sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2\", size = 175248, upload-time = \"2026-07-06T21:33:19.799Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl\", hash = \"sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512\", size = 185717, upload-time = \"2026-07-06T21:33:21.47Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl\", hash = \"sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f\", size = 180114, upload-time = \"2026-07-06T21:33:22.515Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl\", hash = \"sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a\", size = 194142, upload-time = \"2026-07-06T21:33:23.657Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl\", hash = \"sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3\", size = 196819, upload-time = \"2026-07-06T21:33:25.007Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl\", hash = \"sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d\", size = 184965, upload-time = \"2026-07-06T21:33:26.605Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl\", hash = \"sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac\", size = 184952, upload-time = \"2026-07-06T21:33:27.823Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6\", size = 222353, upload-time = \"2026-07-06T21:33:29.178Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913\", size = 210051, upload-time = \"2026-07-06T21:33:30.534Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d\", size = 208630, upload-time = \"2026-07-06T21:33:31.753Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5\", size = 221593, upload-time = \"2026-07-06T21:33:33.044Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl\", hash = \"sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce\", size = 225146, upload-time = \"2026-07-06T21:33:34.224Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl\", hash = \"sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326\", size = 223240, upload-time = \"2026-07-06T21:33:35.57Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl\", hash = \"sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd\", size = 177723, upload-time = \"2026-07-06T21:33:51.626Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl\", hash = \"sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb\", size = 187937, upload-time = \"2026-07-06T21:33:53.403Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl\", hash = \"sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804\", size = 183001, upload-time = \"2026-07-06T21:33:54.74Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl\", hash = \"sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714\", size = 188538, upload-time = \"2026-07-06T21:33:36.792Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl\", hash = \"sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376\", size = 188230, upload-time = \"2026-07-06T21:33:38.011Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98\", size = 223899, upload-time = \"2026-07-06T21:33:39.514Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13\", size = 211652, upload-time = \"2026-07-06T21:33:40.851Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d\", size = 210755, upload-time = \"2026-07-06T21:33:42.183Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056\", size = 223933, upload-time = \"2026-07-06T21:33:43.603Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl\", hash = \"sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4\", size = 226749, upload-time = \"2026-07-06T21:33:45.046Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl\", hash = \"sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94\", size = 225703, upload-time = \"2026-07-06T21:33:46.374Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl\", hash = \"sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76\", size = 182857, upload-time = \"2026-07-06T21:33:47.696Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl\", hash = \"sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5\", size = 194065, upload-time = \"2026-07-06T21:33:48.953Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl\", hash = \"sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8\", size = 186404, upload-time = \"2026-07-06T21:33:50.309Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl\", hash = \"sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c\", size = 194121, upload-time = \"2026-07-06T21:33:56.109Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl\", hash = \"sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001\", size = 196820, upload-time = \"2026-07-06T21:33:57.288Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl\", hash = \"sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3\", size = 184936, upload-time = \"2026-07-06T21:33:58.765Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl\", hash = \"sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc\", size = 185045, upload-time = \"2026-07-06T21:34:00.085Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699\", size = 222342, upload-time = \"2026-07-06T21:34:01.814Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022\", size = 210073, upload-time = \"2026-07-06T21:34:03.255Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0\", size = 208551, upload-time = \"2026-07-06T21:34:04.433Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1\", size = 221649, upload-time = \"2026-07-06T21:34:06.157Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl\", hash = \"sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28\", size = 225203, upload-time = \"2026-07-06T21:34:07.489Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl\", hash = \"sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629\", size = 223263, upload-time = \"2026-07-06T21:34:08.712Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl\", hash = \"sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6\", size = 177696, upload-time = \"2026-07-06T21:34:26.355Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl\", hash = \"sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853\", size = 187914, upload-time = \"2026-07-06T21:34:27.58Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl\", hash = \"sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda\", size = 183004, upload-time = \"2026-07-06T21:34:28.905Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl\", hash = \"sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc\", size = 188378, upload-time = \"2026-07-06T21:34:09.926Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl\", hash = \"sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca\", size = 188319, upload-time = \"2026-07-06T21:34:11.101Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d\", size = 223904, upload-time = \"2026-07-06T21:34:12.606Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl\", hash = \"sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8\", size = 211554, upload-time = \"2026-07-06T21:34:13.987Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl\", hash = \"sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd\", size = 210795, upload-time = \"2026-07-06T21:34:15.972Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f\", size = 223843, upload-time = \"2026-07-06T21:34:17.509Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl\", hash = \"sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc\", size = 226773, upload-time = \"2026-07-06T21:34:19.05Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl\", hash = \"sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9\", size = 225719, upload-time = \"2026-07-06T21:34:20.576Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl\", hash = \"sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b\", size = 182760, upload-time = \"2026-07-06T21:34:22.059Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl\", hash = \"sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5\", size = 193769, upload-time = \"2026-07-06T21:34:23.589Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl\", hash = \"sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210\", size = 186405, upload-time = \"2026-07-06T21:34:24.857Z\" },\n+]\n+\n [[package]]\n name = \"click\"\n version = \"8.3.1\"\n@@ -63,6 +174,62 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl\", hash = \"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6\", size = 25335, upload-time = \"2022-10-25T02:36:20.889Z\" },\n ]\n \n+[[package]]\n+name = \"cryptography\"\n+version = \"49.0.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+ { name = \"cffi\", marker = \"platform_python_implementation != 'PyPy'\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz\", hash = \"sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493\", size = 854345, upload-time = \"2026-06-12T20:02:30.512Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl\", hash = \"sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db\", size = 4032100, upload-time = \"2026-06-12T20:02:32.143Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db\", size = 4692978, upload-time = \"2026-06-12T20:01:21.305Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325\", size = 4716422, upload-time = \"2026-06-12T20:01:48.566Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl\", hash = \"sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2\", size = 4700503, upload-time = \"2026-06-12T20:02:47.091Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl\", hash = \"sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b\", size = 5309779, upload-time = \"2026-06-12T20:02:08.987Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl\", hash = \"sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6\", size = 4749683, upload-time = \"2026-06-12T20:02:03.335Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl\", hash = \"sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c\", size = 4337874, upload-time = \"2026-06-12T20:02:54.323Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl\", hash = \"sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7\", size = 4700283, upload-time = \"2026-06-12T20:01:34.822Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl\", hash = \"sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68\", size = 5265844, upload-time = \"2026-06-12T20:01:24.09Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl\", hash = \"sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9\", size = 4749290, upload-time = \"2026-06-12T20:01:30.848Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl\", hash = \"sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f\", size = 4834612, upload-time = \"2026-06-12T20:01:29.246Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl\", hash = \"sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459\", size = 4980804, upload-time = \"2026-06-12T20:01:42.853Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl\", hash = \"sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e\", size = 3810026, upload-time = \"2026-06-12T20:02:39.262Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl\", hash = \"sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7\", size = 4013892, upload-time = \"2026-06-12T20:02:10.735Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d\", size = 4678835, upload-time = \"2026-06-12T20:02:48.743Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa\", size = 4697239, upload-time = \"2026-06-12T20:02:56.03Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl\", hash = \"sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb\", size = 4685593, upload-time = \"2026-06-12T20:02:50.666Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl\", hash = \"sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d\", size = 5289961, upload-time = \"2026-06-12T20:01:46.468Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl\", hash = \"sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561\", size = 4731145, upload-time = \"2026-06-12T20:02:16.832Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl\", hash = \"sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122\", size = 4321719, upload-time = \"2026-06-12T20:02:52.611Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl\", hash = \"sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505\", size = 4685209, upload-time = \"2026-06-12T20:02:07.282Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl\", hash = \"sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866\", size = 5246285, upload-time = \"2026-06-12T20:01:32.439Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl\", hash = \"sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8\", size = 4730441, upload-time = \"2026-06-12T20:02:01.469Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl\", hash = \"sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3\", size = 4815869, upload-time = \"2026-06-12T20:01:36.574Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl\", hash = \"sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27\", size = 4960948, upload-time = \"2026-06-12T20:02:18.688Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl\", hash = \"sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61\", size = 3799153, upload-time = \"2026-06-12T20:01:39.059Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl\", hash = \"sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a\", size = 4025947, upload-time = \"2026-06-12T20:01:25.745Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl\", hash = \"sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4\", size = 4692429, upload-time = \"2026-06-12T20:01:53.628Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl\", hash = \"sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18\", size = 4700968, upload-time = \"2026-06-12T20:02:45.383Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl\", hash = \"sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69\", size = 4697758, upload-time = \"2026-06-12T20:01:41.13Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl\", hash = \"sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64\", size = 5298863, upload-time = \"2026-06-12T20:02:24.579Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl\", hash = \"sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21\", size = 4735983, upload-time = \"2026-06-12T20:01:50.14Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl\", hash = \"sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9\", size = 4334173, upload-time = \"2026-06-12T20:01:44.743Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl\", hash = \"sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc\", size = 4697298, upload-time = \"2026-06-12T20:02:20.918Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl\", hash = \"sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8\", size = 5254338, upload-time = \"2026-06-12T20:02:22.737Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl\", hash = \"sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36\", size = 4735650, upload-time = \"2026-06-12T20:02:41.389Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl\", hash = \"sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e\", size = 4834820, upload-time = \"2026-06-12T20:01:51.847Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl\", hash = \"sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b\", size = 4967968, upload-time = \"2026-06-12T20:02:12.524Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl\", hash = \"sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001\", size = 3785547, upload-time = \"2026-06-12T20:02:26.847Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl\", hash = \"sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b\", size = 3993685, upload-time = \"2026-06-12T20:02:14.883Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl\", hash = \"sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838\", size = 4676239, upload-time = \"2026-06-12T20:02:28.793Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl\", hash = \"sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5\", size = 4715584, upload-time = \"2026-06-12T20:01:27.495Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl\", hash = \"sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615\", size = 4675885, upload-time = \"2026-06-12T20:01:55.49Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl\", hash = \"sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6\", size = 4715449, upload-time = \"2026-06-12T20:02:05.469Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl\", hash = \"sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6\", size = 3783731, upload-time = \"2026-06-12T20:02:43.319Z\" },\n+]\n+\n [[package]]\n name = \"evaleval\"\n version = \"0.2.7\"\n@@ -125,6 +292,67 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl\", hash = \"sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad\", size = 73517, upload-time = \"2024-12-06T15:37:21.509Z\" },\n ]\n \n+[[package]]\n+name = \"hypothesis\"\n+version = \"6.156.7\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+ { name = \"sortedcontainers\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/39/a8/654d7533861cfdc291e55d5c335fdff2958dfd4833f7ff17c17de063b036/hypothesis-6.156.7.tar.gz\", hash = \"sha256:a646061075d13ebeb763eeafe3a68604483678b2c0eed90dde2ab8ff21abb62a\", size = 476259, upload-time = \"2026-07-18T12:16:31.862Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/e6/d5/d52b00c6b7a059695beb1a68cfb76f9e9b50cbc3655278a671243d447961/hypothesis-6.156.7-cp310-abi3-macosx_10_12_x86_64.whl\", hash = \"sha256:cb227db6ab96667b44ace873cb5d6e9b1819c362d5ad30267ec3f279be6059dd\", size = 748085, upload-time = \"2026-07-18T12:15:39.252Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/c2/0e/7d9408e51862774b8ea09da12d3159efd0d4e33d27416e3139de40f26383/hypothesis-6.156.7-cp310-abi3-macosx_11_0_arm64.whl\", hash = \"sha256:8706f34f3c6d84db5d5a857920da27412e598a134a935070f2466f1cd32cf598\", size = 742726, upload-time = \"2026-07-18T12:15:28.506Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/72/9f/12584e813b8e1d807344b02976a98808cc9a41fe2c64aedf1d86b0ed8dd2/hypothesis-6.156.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:ac49a158abcad440513b463b7a1e8471a0ff2c3c31f8f01a61aae1e9dac19756\", size = 1070224, upload-time = \"2026-07-18T12:16:04.786Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/74/7d/036e0ba919c592d375ca82fc452c9f1a7bd0a32092fabf4e0849c18fc206/hypothesis-6.156.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:aec915de6c6ee5adaa05a22602d1ed521e1b187e6b23d416741a0aa9dea75923\", size = 1121701, upload-time = \"2026-07-18T12:15:24.003Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/de/5c/bec7b48fc78c3683ee01e9111262a6871a9a8c571d9a2698d5646cba63a4/hypothesis-6.156.7-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl\", hash = \"sha256:317b828b52fb42184541c56c501b14f19cdf478e5cc27f0ff327b08ceb8048bc\", size = 1111143, upload-time = \"2026-07-18T12:15:15.496Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/76/b1/23803fd70851bf10eeb1b1ad747b813a94ff1ff479721870658a2fef366d/hypothesis-6.156.7-cp310-abi3-musllinux_1_2_aarch64.whl\", hash = \"sha256:8f1d9b2b80a961745e8603983e43c79721bd1e08c7b11e59782fbe5d999f2e9e\", size = 1244920, upload-time = \"2026-07-18T12:16:12.689Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/09/01/6c2826eb077cb2815e4b6b53261650af959f94a0fd7019ea5681bcf0bc07/hypothesis-6.156.7-cp310-abi3-musllinux_1_2_x86_64.whl\", hash = \"sha256:90709a411250637a4a3e7e025f5c5934ce26000566b0a9c257311972185f1574\", size = 1288673, upload-time = \"2026-07-18T12:15:14.093Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d4/e2/1acf3718536ed6ed96bdb8d034dcb28c8370be2382c954e7dd143cf2b298/hypothesis-6.156.7-cp310-abi3-win32.whl\", hash = \"sha256:6a5ddbb137b56829b743420865788947a333289b2809cbb5cc3396f22f497ae0\", size = 635183, upload-time = \"2026-07-18T12:15:41.851Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/13/dc/8fb902c649444357b5732a316dbb7f7b6cee65440e469db45578d60d7ae7/hypothesis-6.156.7-cp310-abi3-win_amd64.whl\", hash = \"sha256:84d876ca599d8b5131b218050708750c848e5c0513210423e49f3518035eda3f\", size = 640977, upload-time = \"2026-07-18T12:15:25.168Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a7/6b/b97e666503ff04af8d8a4bebac26c685f05b70394f0466d71a648ab3719b/hypothesis-6.156.7-cp311-cp311-macosx_10_12_x86_64.whl\", hash = \"sha256:b486272b3fbef0adea2f2b9f929642f7aaa454447a39bb988bb404372537cbff\", size = 748550, upload-time = \"2026-07-18T12:15:57.109Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/db/3a/574b8476607e6dbdbde56e1732c3b73fa63486babbd9676f677d6d2e7fea/hypothesis-6.156.7-cp311-cp311-macosx_11_0_arm64.whl\", hash = \"sha256:cf115f228cfefe7651a66fd25d601783b11366aaa336d826a6820f5d4a111967\", size = 743341, upload-time = \"2026-07-18T12:16:09.45Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/21/c2/55d18d5fd99254307b776f340390930ba5548c9e41999aa6c5a4f7a76a28/hypothesis-6.156.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:e373bfbeccd0b4ea181e1076ca31b6252f70d95fd735e867959074d5d9638eaa\", size = 1070864, upload-time = \"2026-07-18T12:15:33.683Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/cd/07/4170c3000ba06b6d3fd0e4434d49ce69044dcc161038ee36010018fb9d9e/hypothesis-6.156.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:f5efbc419e7b2774a5c66e7349851a58a69bc56138ee70d543ec1d4833aa8883\", size = 1122080, upload-time = \"2026-07-18T12:16:25.889Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/f3/d5/fff0db43896163f9a8a115e942c60f2d07fe75fb8d339ed42aec83dfcffb/hypothesis-6.156.7-cp311-cp311-musllinux_1_2_aarch64.whl\", hash = \"sha256:080439d4930600742e4f7878929e71a3c4f0e31f4ac99812d675ed95086a221b\", size = 1245772, upload-time = \"2026-07-18T12:16:07.801Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/8d/96/d449714acd4a4cad5590d74f4644f2a1c163b8055eebbe6a6278400fdaab/hypothesis-6.156.7-cp311-cp311-musllinux_1_2_x86_64.whl\", hash = \"sha256:43e2aef269045af0531f4facd0b93f1c7c6d50147ce69a9b0a8b0285b0f13638\", size = 1289045, upload-time = \"2026-07-18T12:16:30.36Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/69/80/3872f8fbe9b2035b7a44c0911cc52b40bed75388e99b5c298077b75c00b1/hypothesis-6.156.7-cp311-cp311-win_amd64.whl\", hash = \"sha256:f9b1679aa75ab8452767cd8af1a91de0b8615b2057134079f78628faf9f49bfb\", size = 640786, upload-time = \"2026-07-18T12:15:17.963Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/10/46/65e5ec7694b88af0e4b79cd0972ef4bc9b6b1ef4e5a7de98c45cc63a19f0/hypothesis-6.156.7-cp312-cp312-macosx_10_12_x86_64.whl\", hash = \"sha256:a8ec2634571c1048d6c69a620aacae0a43533ea2fe36aff52a0d27720f2f8017\", size = 748950, upload-time = \"2026-07-18T12:15:44.372Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/21/59/f00a5e5a7504d6c999042d0ea98b4fb4c7fa45523379b5c1233f667b1425/hypothesis-6.156.7-cp312-cp312-macosx_11_0_arm64.whl\", hash = \"sha256:3f122fc5db966d4f66b61c37c8e69ed38290891dc11aee4ca0ac1ac07d31cc6b\", size = 741468, upload-time = \"2026-07-18T12:15:08.797Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/42/d7/649c22c1ac2d273293b91977a10adb40ad59663bf23496a4c224eb5d150b/hypothesis-6.156.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:aa2e4958fd25622303824b023721f4199110473cf34e29d331db255568ee9931\", size = 1069808, upload-time = \"2026-07-18T12:15:40.644Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/f5/bf/d8053ff8f0d9506098ba5373543be4f84eba14543f506281bbc2d5da481f/hypothesis-6.156.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:9841c485148830f2f4755923b4ef3648c42792bf9d8896b3a530d6d7b7999eef\", size = 1121058, upload-time = \"2026-07-18T12:16:28.879Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/a6/62/fa89af5dd25a181cdc9bf5c6dd706ced8b2bf886f6537151c06a25b65a0e/hypothesis-6.156.7-cp312-cp312-musllinux_1_2_aarch64.whl\", hash = \"sha256:b3fdcba3697df6981c4d9916b1cb03229cb92235db27e410447db5fe564b9598\", size = 1244097, upload-time = \"2026-07-18T12:15:30.95Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/e1/63/c95a954fbd6c2059141be356afbefda6a7a7955f104d0d3cdc6a2b5415ac/hypothesis-6.156.7-cp312-cp312-musllinux_1_2_x86_64.whl\", hash = \"sha256:7fd38f6879808194d1e196b26d0f335fb9fd717ce3fe3ab2ab79562d7d22cd17\", size = 1287646, upload-time = \"2026-07-18T12:16:15.853Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/39/ff/db93ab090e7a05fda97900f4290a3cab67e777bce47ca5d3cf59a68f02bc/hypothesis-6.156.7-cp312-cp312-win_amd64.whl\", hash = \"sha256:6bd58e06628863212ecb46c80546ba6bb6ab8d018b6d3f5cf3ad0ed639c5a3cd\", size = 638291, upload-time = \"2026-07-18T12:15:58.44Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/57/c7/80313bf239c6431f05b5e98cf6d4241cd6d1f570e4f0953a09ba0e034a5c/hypothesis-6.156.7-cp313-cp313-macosx_10_12_x86_64.whl\", hash = \"sha256:bc0688c17802bad5dc77c5d56f2d2f57bdcde52350f1858bd44f156608d29311\", size = 749321, upload-time = \"2026-07-18T12:15:46.929Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/97/60/54693423e0b0df8005532fa0f70daa31ef023e2d974acb465e87e520cf0e/hypothesis-6.156.7-cp313-cp313-macosx_11_0_arm64.whl\", hash = \"sha256:dd3aa1bc6994ffd9b7c484dc6ecdd07455dabaa171cc1665b3541309c2203721\", size = 741778, upload-time = \"2026-07-18T12:15:48.359Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/10/5b/6090f8f47d2a5d95ee7863bccc71f448a58001ad4b556fa47f8ec588018f/hypothesis-6.156.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:160c74f54576e566922055730c578c61b421069f5aeec21fbe3759284071aed6\", size = 1070014, upload-time = \"2026-07-18T12:15:53.958Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/78/0f/7d46320495f9ddb42ea0e99652a49c118a4c289a46ce2e72ea729e056a8c/hypothesis-6.156.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:e8b52ac55d0a8e09254840f81787a3a727f771cb7af46dd854139b438ab4d7e1\", size = 1121225, upload-time = \"2026-07-18T12:16:20.907Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/1f/99/7dfbed6ee39b8899197c8dd68adbc364a0d6594d07a3bf14880cbf25ba31/hypothesis-6.156.7-cp313-cp313-musllinux_1_2_aarch64.whl\", hash = \"sha256:ad5f3250ed768b2b98088d3fd3c902fadb4edf2282ed1751e18e712b55fbd060\", size = 1244459, upload-time = \"2026-07-18T12:16:22.613Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/f6/63/3d2ce8803616bc4528e253a744477ca8ba61e6c5ae9ce0bfb751a16d3892/hypothesis-6.156.7-cp313-cp313-musllinux_1_2_x86_64.whl\", hash = \"sha256:79c90a535ca3910af614b568a487545b4ce20ee3ddcdd3c25a27628e355e9470\", size = 1287988, upload-time = \"2026-07-18T12:16:14.248Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/3c/92/d8454244f55ad18b0392cc44e982111b8595348ff7c704db07f65a8df28e/hypothesis-6.156.7-cp313-cp313-win_amd64.whl\", hash = \"sha256:5f7b92b8aa2803881e9aed3511cb2fb88e1aa9dc615dd1e4ce68d51a5035e86b\", size = 638511, upload-time = \"2026-07-18T12:15:45.728Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/ca/09/cf6e1eef6d56abd2b87551bcbe926e1ddf2f099b98bd8f0aa31e4cecd8a3/hypothesis-6.156.7-cp314-cp314-macosx_10_12_x86_64.whl\", hash = \"sha256:9c3ef9b0370e393617266d645a574fbebcbd25ead0c9058a17e2278790f7bdf7\", size = 749397, upload-time = \"2026-07-18T12:15:51.213Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/5e/f4/a451d9220635f4f6c9de7d28247ec2ca8cc8162abf4cbcc8785831e0b058/hypothesis-6.156.7-cp314-cp314-macosx_11_0_arm64.whl\", hash = \"sha256:ecec3ab386f1f4f4d8607dcecc9e3ff6924a9c8cf2d8f5f5dcde37d99fae08d2\", size = 741909, upload-time = \"2026-07-18T12:16:27.361Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/87/cd/40e3c78289619fc65412b2fb2bb43efd61c87cd97d29b4b397f365b20fbb/hypothesis-6.156.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:24e09a82fb80166a9e92a8579537fae2858946dcf364de21bbb6fa529d5b4848\", size = 1070292, upload-time = \"2026-07-18T12:16:01.482Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6b/1b/ad83da2595b71d078f252d034aa49c396fcdd94a15a4535407fe106f0629/hypothesis-6.156.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:c9e3d6120bb4069e63b555b1b3e6eac6ef662acfb3146cd2579bc226c9c92aee\", size = 1121520, upload-time = \"2026-07-18T12:15:16.638Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d0/d6/318524efa95ac9868a7589dce676c5241b56d2c6ad39b0b6e2a6d0669eff/hypothesis-6.156.7-cp314-cp314-musllinux_1_2_aarch64.whl\", hash = \"sha256:992b3c8424e9d8daf27107bbb14fb194f9155edcea28f0c6efd66b007fe7cfa3\", size = 1244807, upload-time = \"2026-07-18T12:15:35.541Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/10/da/8225a4e51c2ba06152cf5e89566cb5d1eef7c16132cfe134774355677d22/hypothesis-6.156.7-cp314-cp314-musllinux_1_2_x86_64.whl\", hash = \"sha256:783033b5d398272b332705306612fd6280b23ba7fa818221cf68c08604a3a532\", size = 1288261, upload-time = \"2026-07-18T12:15:49.82Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/51/6d/1722c49b2eaf728c08591e3b1a7c77a06664e8227acda5a23e323fa47969/hypothesis-6.156.7-cp314-cp314-pyemscripten_2026_0_wasm32.whl\", hash = \"sha256:d780b494c9caa06cb17b69fa87af688fc9944203d7fa050d41fba2db370bf03a\", size = 586675, upload-time = \"2026-07-18T12:16:24.252Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/d3/d5/ec1da06da07ee1fe7fc2ca31fdc9f94653f02b684485fb0972e330e80a5f/hypothesis-6.156.7-cp314-cp314-win_amd64.whl\", hash = \"sha256:a612ed61dc2341d42f282caca98787e85534613381ba0d9cc2829858d293985d\", size = 638331, upload-time = \"2026-07-18T12:16:19.062Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/f0/f7/d0c4fdc42d1c70c76ebeaf51295fedb1401457bd4bfef033d76a22594c8d/hypothesis-6.156.7-cp314-cp314t-macosx_10_12_x86_64.whl\", hash = \"sha256:847a07059a1029ae64c5fd1480737d3615326424cfe91c89e47e4ea3a0b543e7\", size = 747280, upload-time = \"2026-07-18T12:16:03.41Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/61/c1/15fd29960f31ec95cc7300f21e74f0a54a68e0c118ca5822e817929ef70b/hypothesis-6.156.7-cp314-cp314t-macosx_11_0_arm64.whl\", hash = \"sha256:b933f54a8176d5f0e7fdf73ff933154ec5551ec0996081ae01bec75ecea1ef26\", size = 740552, upload-time = \"2026-07-18T12:15:43.098Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/14/0e/22564a3e479850c86fcf835eaea8551da1f79e5f687fe1f84b7c24c40bfa/hypothesis-6.156.7-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:540933cdb53d0d08ee3fd47af3d78ed6b785db434afe12f08dc9f4b56400c5c9\", size = 1069465, upload-time = \"2026-07-18T12:15:32.314Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/7b/e0/add146dfd54d4b8616053823da30385f4c959a80903f9b6825fc39a8a687/hypothesis-6.156.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:53f53c665a82e75da49a78e33a353dd654afa96176d7c396ea59c3574c0ed3f6\", size = 1120529, upload-time = \"2026-07-18T12:15:22.766Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/12/33/4d9d4c57933b98df397a6a0d5f3700c2389358580d780661cfccbd43c836/hypothesis-6.156.7-cp314-cp314t-musllinux_1_2_aarch64.whl\", hash = \"sha256:a33a1e82149916c4662811e48870aa0dab45ecca0dd5a76e6bae39067e6f4cff\", size = 1243233, upload-time = \"2026-07-18T12:15:29.65Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/47/b6/c476ba0ba65e8640e38ce3971d0a63d492d612ab3fcc724a5662395f89b6/hypothesis-6.156.7-cp314-cp314t-musllinux_1_2_x86_64.whl\", hash = \"sha256:96553167deab92c5d02d3854d865ae8e66850491eedda45f8306e88fe906e03f\", size = 1287045, upload-time = \"2026-07-18T12:16:11.136Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/de/75/43e4dec9ccd56ed665f9dd3e78398453e16b41dac33ed56ea2efa92e3024/hypothesis-6.156.7-cp314-cp314t-win_amd64.whl\", hash = \"sha256:7884cea7ebe616690f976eae26cd7562dbda6030602ba32e0666681f3134d37a\", size = 638419, upload-time = \"2026-07-18T12:15:36.804Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/4e/91/8932195b775d9c558dc0841b6ee25f774484ffed8f61bdb0552187cd1e81/hypothesis-6.156.7-pp311-pypy311_pp73-macosx_10_12_x86_64.whl\", hash = \"sha256:bfd514af542ca7b7a6c6f1ecaf825f074996a712e14a29382b83b9233981ba34\", size = 749266, upload-time = \"2026-07-18T12:15:52.523Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/6d/d5/a69882dc126c01f04c68bb1d2b7c24e972959f2b363de4c4db32d6b1bc7b/hypothesis-6.156.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl\", hash = \"sha256:f8542dccbad2592c7e6eabfa8957987bc65b6b51862df63bbc6ef75e36734287\", size = 743982, upload-time = \"2026-07-18T12:15:38.052Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/00/5d/2937c75a015564e75ca2ea8ab287f82bdf5f954ff523cd1c35c27315d0e9/hypothesis-6.156.7-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl\", hash = \"sha256:72e964b04ec22b2273e0646b47686682f0df5a81c122c48da8acac2e0d17b8da\", size = 1071135, upload-time = \"2026-07-18T12:15:11.819Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/7c/a9/f49de6cd6d154683c3d5e5daf89eae8a1ebee394021fe7a20515bc1293bf/hypothesis-6.156.7-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl\", hash = \"sha256:4d97075ab6d97dd62e940cb75f26bc6a23a3e3376324dd754df506bcfbafcab6\", size = 1122685, upload-time = \"2026-07-18T12:16:17.345Z\" },\n+ { url = \"https://files.pythonhosted.org/packages/21/6f/cfc4a6ca25c086f3622d21ef5d334de3633d5b8692a3165e16b12686ebb3/hypothesis-6.156.7-pp311-pypy311_pp73-win_amd64.whl\", hash = \"sha256:cfd381e3d32a5e85fda2038a06fcfafea953e53217ca2d4b090ca576eab70c38\", size = 641377, upload-time = \"2026-07-18T12:15:19.719Z\" },\n+]\n+\n [[package]]\n name = \"idna\"\n version = \"3.11\"\n@@ -152,6 +380,27 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl\", hash = \"sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef\", size = 16234, upload-time = \"2024-04-16T21:28:14.499Z\" },\n ]\n \n+[[package]]\n+name = \"joserfc\"\n+version = \"1.7.3\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+ { name = \"cryptography\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/d4/c6/b1cac0280f8efc57626ea8804866b37099f23cae11b1485a42b213245e31/joserfc-1.7.3.tar.gz\", hash = \"sha256:116955c2587139dba20621fd0bd7fc9255fa960c9fe7f43c43ebef2e801dcfcf\", size = 233821, upload-time = \"2026-07-08T12:41:42.66Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/7d/f5/650b59d1b74f5befb7a7a7e7d7c92a26b94256df3541e2b4914152cd177a/joserfc-1.7.3-py3-none-any.whl\", hash = \"sha256:7c39f3f2c943dbc03122747fa8ebbd8e156e54904cf25651b452f4d2634a6075\", size = 70982, upload-time = \"2026-07-08T12:41:41.521Z\" },\n+]\n+\n+[[package]]\n+name = \"mpmath\"\n+version = \"1.3.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz\", hash = \"sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f\", size = 508106, upload-time = \"2023-03-07T16:47:11.061Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl\", hash = \"sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c\", size = 536198, upload-time = \"2023-03-07T16:47:09.197Z\" },\n+]\n+\n [[package]]\n name = \"numpy\"\n version = \"2.4.3\"\n@@ -249,6 +498,15 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl\", hash = \"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746\", size = 20538, upload-time = \"2025-05-15T12:30:06.134Z\" },\n ]\n \n+[[package]]\n+name = \"pycparser\"\n+version = \"3.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz\", hash = \"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29\", size = 103492, upload-time = \"2026-01-21T14:26:51.89Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl\", hash = \"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992\", size = 48172, upload-time = \"2026-01-21T14:26:50.693Z\" },\n+]\n+\n [[package]]\n name = \"pydantic\"\n version = \"2.12.5\"\n@@ -386,35 +644,61 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl\", hash = \"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b\", size = 374801, upload-time = \"2025-12-06T21:30:49.154Z\" },\n ]\n \n+[[package]]\n+name = \"python-multipart\"\n+version = \"0.0.32\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz\", hash = \"sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e\", size = 46881, upload-time = \"2026-06-04T16:18:58.647Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl\", hash = \"sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23\", size = 30042, upload-time = \"2026-06-04T16:18:57.319Z\" },\n+]\n+\n [[package]]\n name = \"slug-constitution\"\n version = \"0.1.0\"\n source = { virtual = \".\" }\n dependencies = [\n+ { name = \"authlib\" },\n { name = \"evaleval\" },\n { name = \"fastapi\" },\n { name = \"httpx\" },\n+ { name = \"hypothesis\" },\n { name = \"itsdangerous\" },\n { name = \"numpy\" },\n { name = \"pytest\" },\n+ { name = \"python-multipart\" },\n { name = \"starlette\" },\n+ { name = \"sympy\" },\n { name = \"tenacity\" },\n { name = \"uvicorn\" },\n ]\n \n [package.metadata]\n requires-dist = [\n- { name = \"evaleval\", specifier = \">=0.2.6\" },\n+ { name = \"authlib\" },\n+ { name = \"evaleval\", specifier = \"==0.2.7\" },\n { name = \"fastapi\" },\n { name = \"httpx\" },\n+ { name = \"hypothesis\" },\n { name = \"itsdangerous\" },\n { name = \"numpy\" },\n { name = \"pytest\" },\n+ { name = \"python-multipart\" },\n { name = \"starlette\" },\n+ { name = \"sympy\" },\n { name = \"tenacity\" },\n { name = \"uvicorn\" },\n ]\n \n+[[package]]\n+name = \"sortedcontainers\"\n+version = \"2.4.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+sdist = { url = \"https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz\", hash = \"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88\", size = 30594, upload-time = \"2021-05-16T22:03:42.897Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl\", hash = \"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0\", size = 29575, upload-time = \"2021-05-16T22:03:41.177Z\" },\n+]\n+\n [[package]]\n name = \"starlette\"\n version = \"1.0.0\"\n@@ -428,6 +712,18 @@ wheels = [\n { url = \"https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl\", hash = \"sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b\", size = 72651, upload-time = \"2026-03-22T18:29:45.111Z\" },\n ]\n \n+[[package]]\n+name = \"sympy\"\n+version = \"1.14.0\"\n+source = { registry = \"https://pypi.org/simple\" }\n+dependencies = [\n+ { name = \"mpmath\" },\n+]\n+sdist = { url = \"https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz\", hash = \"sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517\", size = 7793921, upload-time = \"2025-04-27T18:05:01.611Z\" }\n+wheels = [\n+ { url = \"https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl\", hash = \"sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5\", size = 6299353, upload-time = \"2025-04-27T18:04:59.103Z\" },\n+]\n+\n [[package]]\n name = \"tenacity\"\n version = \"9.1.4\"\n","role":"user"}],"model":"~x-ai/grok-latest"}