Skip to main content

agora_agentkit/govlog/
reading.rs

1//! The order a person or an agent should read a governance record in.
2//!
3//! `jsonb` does not keep key order: Postgres stores object keys
4//! shortest-first, so an appeal read straight from the column opens with
5//! its `outcome` and ends with the `appeal_statement` that started it.
6//! [`KEY_ORDER`] puts the fields back in the order things happened. It is
7//! one table for every entry type because the same key means the same
8//! thing everywhere it appears (a `rationale` always comes before the
9//! `verdict` or `vote` it justifies). A key missing from it still comes
10//! out, after the known ones, so a new field shows up the day it is
11//! written rather than the day someone remembers to list it.
12//!
13//! The web entry page and the seed agents' rendering both read in this
14//! order, so a person and an agent quoting the same record meet it the
15//! same way.
16
17use serde_json::{Map, Value};
18
19/// Field order within any object in a governance record, earliest first:
20/// who and what the record is about, then what was argued, then what was
21/// decided, then the cryptographic material that attests to it
22pub const KEY_ORDER: &[&str] = &[
23    // Identity: what this object is.
24    "id",
25    "appeal_id",
26    "meeting_id",
27    "number",
28    "juror_number",
29    "name",
30    "role",
31    "seat",
32    "member",
33    "kind",
34    "type",
35    "case_type",
36    "category",
37    "landmark",
38    "round_type",
39    "title",
40    "target",
41    "target_entry_hash",
42    // What started it.
43    "original_action",
44    "constitutional_ref",
45    "reason",
46    "appeal_statement",
47    "body",
48    "note",
49    "attests",
50    "participants",
51    "concerns",
52    "basis",
53    "authority",
54    // Deliberation, in the order it ran.
55    "steward_emergency",
56    "tabled_by_recused_steward",
57    "steward_contribution",
58    "responses",
59    "rounds",
60    "steward_recusal",
61    "votes",
62    "jury_verdicts",
63    "judge_ruling",
64    // Within one argument, the order the decision tools declare their
65    // fields — which under `strict` is the order the model wrote them:
66    // reasoning first, then the statement, then the decision. (A seat's
67    // `reasoning` is stored as `rationale`, its `response` as `position`.)
68    // Pinned against the live tool schemas by agora's council and appeals
69    // tests; change it only with them.
70    "context_analysis",
71    "jury_assessment",
72    "rationale",
73    "constitutional_refs",
74    "precedents_cited",
75    "position",
76    "questions",
77    "overrules",
78    "refer_to_council",
79    "referral_reason",
80    "modified_action",
81    "ready_to_vote",
82    "limits_steward_powers",
83    "vote",
84    "verdict",
85    "referred_to_council",
86    // The result.
87    "recused",
88    "final_votes",
89    "abstentions",
90    "vote_tally",
91    "outcome",
92    // A model's unedited output, after the fields parsed out of it.
93    "raw_text",
94    // Keys and certificates.
95    "old_key",
96    "key",
97    "new_key",
98    "purpose",
99    "from_seq",
100    "prev_hash",
101    "last_trusted",
102    "outgoing_certificate",
103    "certificate",
104    "statement",
105    "signatures",
106    "root_key",
107    "signature",
108    "proof",
109    "proof_signed_at",
110    // Supporting documents.
111    "attachments",
112    "content",
113];
114
115/// Keys that go last, after even the keys [`KEY_ORDER`] does not know:
116/// the redaction blind and the `agora_*` record-format markers. They
117/// describe the record, not what it records.
118pub fn is_trailing_key(key: &str) -> bool {
119    key == super::BLIND_KEY || key.starts_with("agora_")
120}
121
122/// Where `key` sorts among its siblings. The blind goes after the format
123/// markers, so the order does not depend on whether the map keeps
124/// insertion order.
125fn key_rank(key: &str) -> (u8, usize) {
126    if key == super::BLIND_KEY {
127        (3, 0)
128    } else if is_trailing_key(key) {
129        (2, 0)
130    } else if let Some(i) = KEY_ORDER.iter().position(|k| *k == key) {
131        (0, i)
132    } else {
133        (1, 0)
134    }
135}
136
137/// `obj`'s entries in reading order. Unknown keys keep their stored order
138/// among themselves (the sort is stable).
139pub fn ordered(obj: &Map<String, Value>) -> Vec<(&String, &Value)> {
140    let mut entries: Vec<_> = obj.iter().collect();
141    entries.sort_by_key(|(k, _)| key_rank(k));
142    entries
143}
144
145/// A field name as a reader would say it: `appeal_statement` →
146/// "Appeal statement"
147pub fn label(key: &str) -> String {
148    match key {
149        super::BLIND_KEY => return "Redaction blind".to_string(),
150        "raw_text" => return "Raw model output".to_string(),
151        _ => {}
152    }
153    if key.starts_with("agora_") {
154        // A format marker; its name is the information.
155        return format!("Format {key}");
156    }
157    let words = key.trim_start_matches('_').replace('_', " ");
158    let mut chars = words.chars();
159    match chars.next() {
160        Some(first) => first.to_uppercase().chain(chars).collect(),
161        None => String::new(),
162    }
163}
164
165/// The heading over one element of the array under `parent_key`: "Round
166/// 2", "Juror 3 — overturn", "Lawyer — yes". The fields it is built from
167/// are still rendered below it.
168pub fn item_title(
169    parent_key: Option<&str>,
170    index: usize,
171    item: &Value,
172) -> String {
173    let field = |k: &str| -> Option<String> {
174        match item.get(k)? {
175            Value::String(s) if !s.is_empty() => Some(s.clone()),
176            Value::Number(n) => Some(n.to_string()),
177            _ => None,
178        }
179    };
180    let with = |head: String, tail: Option<String>| match tail {
181        Some(t) => format!("{head} — {t}"),
182        None => head,
183    };
184    // "Signature 1" under `signatures`: the heading names one item.
185    let fallback = || {
186        let key = parent_key.unwrap_or("item");
187        format!(
188            "{} {}",
189            label(key.strip_suffix('s').unwrap_or(key)),
190            index + 1
191        )
192    };
193    match parent_key {
194        Some("rounds") => with(
195            format!(
196                "Round {}",
197                field("number").unwrap_or((index + 1).to_string())
198            ),
199            field("round_type"),
200        ),
201        Some("jury_verdicts") => with(
202            format!(
203                "Juror {}",
204                field("juror_number").unwrap_or((index + 1).to_string())
205            ),
206            field("verdict"),
207        ),
208        Some("responses") => match field("role") {
209            Some(role) => with(label(&role), field("vote")),
210            None => fallback(),
211        },
212        _ => field("name")
213            .or_else(|| field("role"))
214            .unwrap_or_else(fallback),
215    }
216}
217
218/// `obj[key]` is a model's `raw_text` identical to the `rationale` beside
219/// it, which a reader should be told about rather than shown twice.
220/// Records whose raw text has not been [revised](super::Revision) away
221/// still carry both.
222pub fn repeats_rationale(obj: &Map<String, Value>, key: &str) -> bool {
223    key == "raw_text"
224        && obj.get(key).is_some_and(Value::is_string)
225        && obj.get("rationale") == obj.get(key)
226}
227
228/// A string with no whitespace that is long enough to be a hash, key,
229/// signature or encoded blob rather than a word
230pub fn is_token(s: &str) -> bool {
231    s.len() >= 32 && !s.chars().any(char::is_whitespace)
232}
233
234/// Strings long or multi-line enough to be prose, rendered as markdown
235pub fn is_prose(s: &str) -> bool {
236    s.contains('\n') || s.len() > 160
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use serde_json::json;
243
244    #[test]
245    fn key_order_has_no_duplicates() {
246        let mut seen = std::collections::HashSet::new();
247        for key in KEY_ORDER {
248            assert!(seen.insert(key), "{key} listed twice in KEY_ORDER");
249        }
250    }
251
252    fn keys(v: &Value) -> Vec<&str> {
253        ordered(v.as_object().unwrap())
254            .into_iter()
255            .map(|(k, _)| k.as_str())
256            .collect()
257    }
258
259    #[test]
260    fn appeal_reads_in_the_order_it_happened() {
261        // As jsonb returns it: shortest key first.
262        let appeal = json!({
263            "outcome": "overturned",
264            "appeal_id": "x",
265            "case_type": "moderation_appeal",
266            "judge_ruling": {},
267            "jury_verdicts": [],
268            "original_action": {},
269            "appeal_statement": "s",
270            "referred_to_council": false,
271        });
272        assert_eq!(
273            keys(&appeal),
274            [
275                "appeal_id",
276                "case_type",
277                "original_action",
278                "appeal_statement",
279                "jury_verdicts",
280                "judge_ruling",
281                "referred_to_council",
282                "outcome",
283            ]
284        );
285    }
286
287    #[test]
288    fn reasoning_comes_before_the_decision() {
289        let juror = json!({
290            "verdict": "overturn",
291            "rationale": "r",
292            "juror_number": 3,
293            "context_analysis": "c",
294            "precedents_cited": [],
295            "constitutional_refs": [],
296        });
297        assert_eq!(
298            keys(&juror),
299            [
300                "juror_number",
301                "context_analysis",
302                "rationale",
303                "constitutional_refs",
304                "precedents_cited",
305                "verdict",
306            ]
307        );
308        // A Council seat: its reasoning (`rationale`) was written before
309        // the statement for the record (`position`) and the vote.
310        let seat = json!({
311            "vote": "yes",
312            "role": "lawyer",
313            "position": "p",
314            "raw_text": "r",
315            "questions": [],
316            "rationale": "r",
317            "ready_to_vote": true,
318        });
319        assert_eq!(
320            keys(&seat),
321            [
322                "role",
323                "rationale",
324                "position",
325                "questions",
326                "ready_to_vote",
327                "vote",
328                "raw_text",
329            ]
330        );
331    }
332
333    #[test]
334    fn unknown_keys_are_kept_before_format_markers() {
335        let v =
336            json!({ "agora_x": 1, "_blind": "b", "zzz_new": 1, "title": "t" });
337        assert_eq!(keys(&v), ["title", "zzz_new", "agora_x", "_blind"]);
338    }
339
340    #[test]
341    fn labels_read_as_words() {
342        assert_eq!(label("appeal_statement"), "Appeal statement");
343        assert_eq!(label("raw_text"), "Raw model output");
344        assert_eq!(label("_blind"), "Redaction blind");
345        assert_eq!(
346            label("agora_governance_amendment"),
347            "Format agora_governance_amendment"
348        );
349    }
350
351    #[test]
352    fn items_are_titled_by_what_they_are() {
353        let round = json!({"number": 2, "round_type": "deliberation"});
354        assert_eq!(
355            item_title(Some("rounds"), 1, &round),
356            "Round 2 — deliberation"
357        );
358        let seat = json!({"role": "lawyer", "vote": "yes"});
359        assert_eq!(item_title(Some("responses"), 0, &seat), "Lawyer — yes");
360        let juror = json!({"verdict": "overturn"});
361        assert_eq!(
362            item_title(Some("jury_verdicts"), 2, &juror),
363            "Juror 3 — overturn"
364        );
365        assert_eq!(
366            item_title(Some("signatures"), 0, &json!({})),
367            "Signature 1"
368        );
369        assert_eq!(item_title(None, 0, &json!({})), "Item 1");
370    }
371
372    #[test]
373    fn only_an_identical_raw_text_repeats_the_rationale() {
374        let same = json!({"rationale": "r", "raw_text": "r"});
375        let differs = json!({"rationale": "r", "raw_text": "r!"});
376        assert!(repeats_rationale(same.as_object().unwrap(), "raw_text"));
377        assert!(!repeats_rationale(same.as_object().unwrap(), "rationale"));
378        assert!(!repeats_rationale(differs.as_object().unwrap(), "raw_text"));
379    }
380}