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    // from 0.64 the `rationale` is the thinking the seat did first.)
69    // Pinned against the live tool schemas by agora's council and appeals
70    // tests; change it only with them.
71    "context_analysis",
72    "jury_assessment",
73    "rationale",
74    "constitutional_refs",
75    "precedents_cited",
76    "position",
77    "questions",
78    "overrules",
79    "refer_to_council",
80    "referral_reason",
81    "modified_action",
82    "ready_to_vote",
83    "limits_steward_powers",
84    "vote",
85    "verdict",
86    "referred_to_council",
87    // The result.
88    "recused",
89    "final_votes",
90    "abstentions",
91    "vote_tally",
92    "outcome",
93    // A model's unedited output, after the fields parsed out of it.
94    "raw_text",
95    // Keys and certificates.
96    "old_key",
97    "key",
98    "new_key",
99    "purpose",
100    "from_seq",
101    "prev_hash",
102    "last_trusted",
103    "outgoing_certificate",
104    "certificate",
105    "statement",
106    "signatures",
107    "root_key",
108    "signature",
109    "proof",
110    "proof_signed_at",
111    // Supporting documents.
112    "attachments",
113    "content",
114];
115
116/// Keys that go last, after even the keys [`KEY_ORDER`] does not know:
117/// the redaction blind and the `agora_*` record-format markers. They
118/// describe the record, not what it records.
119pub fn is_trailing_key(key: &str) -> bool {
120    key == super::BLIND_KEY || key.starts_with("agora_")
121}
122
123/// Where `key` sorts among its siblings. The blind goes after the format
124/// markers, so the order does not depend on whether the map keeps
125/// insertion order.
126fn key_rank(key: &str) -> (u8, usize) {
127    if key == super::BLIND_KEY {
128        (3, 0)
129    } else if is_trailing_key(key) {
130        (2, 0)
131    } else if let Some(i) = KEY_ORDER.iter().position(|k| *k == key) {
132        (0, i)
133    } else {
134        (1, 0)
135    }
136}
137
138/// `obj`'s entries in reading order. Unknown keys keep their stored order
139/// among themselves (the sort is stable).
140pub fn ordered(obj: &Map<String, Value>) -> Vec<(&String, &Value)> {
141    let mut entries: Vec<_> = obj.iter().collect();
142    entries.sort_by_key(|(k, _)| key_rank(k));
143    entries
144}
145
146/// A field name as a reader would say it: `appeal_statement` →
147/// "Appeal statement"
148pub fn label(key: &str) -> String {
149    match key {
150        super::BLIND_KEY => return "Redaction blind".to_string(),
151        "raw_text" => return "Raw model output".to_string(),
152        // Not "Ready to vote": beside a seat's vote, "Ready to vote: no" was
153        // read as the vote itself (a GOV-2026-0009 report, 2026-10-08).
154        "ready_to_vote" => return "Ready".to_string(),
155        _ => {}
156    }
157    if key.starts_with("agora_") {
158        // A format marker; its name is the information.
159        return format!("Format {key}");
160    }
161    let words = key.trim_start_matches('_').replace('_', " ");
162    let mut chars = words.chars();
163    match chars.next() {
164        Some(first) => first.to_uppercase().chain(chars).collect(),
165        None => String::new(),
166    }
167}
168
169/// How a boolean field reads: "yes" or "no", except where a bare "no" beside
170/// a seat's vote would read as the vote. A seat not yet ready is "not yet".
171pub fn bool_text(key: &str, value: bool) -> &'static str {
172    match (key, value) {
173        (_, true) => "yes",
174        ("ready_to_vote", false) => "not yet",
175        (_, false) => "no",
176    }
177}
178
179/// The heading over one element of the array under `parent_key`: "Round
180/// 2", "Juror 3 — overturn", "Lawyer — yes". The fields it is built from
181/// are still rendered below it.
182pub fn item_title(
183    parent_key: Option<&str>,
184    index: usize,
185    item: &Value,
186) -> String {
187    let field = |k: &str| -> Option<String> {
188        match item.get(k)? {
189            Value::String(s) if !s.is_empty() => Some(s.clone()),
190            Value::Number(n) => Some(n.to_string()),
191            _ => None,
192        }
193    };
194    let with = |head: String, tail: Option<String>| match tail {
195        Some(t) => format!("{head} — {t}"),
196        None => head,
197    };
198    // "Signature 1" under `signatures`: the heading names one item.
199    let fallback = || {
200        let key = parent_key.unwrap_or("item");
201        format!(
202            "{} {}",
203            label(key.strip_suffix('s').unwrap_or(key)),
204            index + 1
205        )
206    };
207    match parent_key {
208        Some("rounds") => with(
209            format!(
210                "Round {}",
211                field("number").unwrap_or((index + 1).to_string())
212            ),
213            field("round_type"),
214        ),
215        Some("jury_verdicts") => with(
216            format!(
217                "Juror {}",
218                field("juror_number").unwrap_or((index + 1).to_string())
219            ),
220            field("verdict"),
221        ),
222        Some("responses") => match field("role") {
223            Some(role) => with(label(&role), field("vote")),
224            None => fallback(),
225        },
226        _ => field("name")
227            .or_else(|| field("role"))
228            .unwrap_or_else(fallback),
229    }
230}
231
232/// `obj[key]` is a model's `raw_text` identical to the `rationale` beside
233/// it, which a reader should be told about rather than shown twice.
234/// Records whose raw text has not been [revised](super::Revision) away
235/// still carry both.
236pub fn repeats_rationale(obj: &Map<String, Value>, key: &str) -> bool {
237    key == "raw_text"
238        && obj.get(key).is_some_and(Value::is_string)
239        && obj.get("rationale") == obj.get(key)
240}
241
242/// What a reader is told once about a record whose seats' rationales are
243/// [thinking](super::Rationale::Thinking)
244pub const THINKING_NOTE: &str = "From October 2026 a seat's rationale is its \
245thinking before it answered. On Anthropic models that is a summary of the \
246model's reasoning, written by Anthropic and signed over the full \
247reasoning; Anthropic does not return the reasoning itself. Before then \
248the seats wrote their reasoning into a `reasoning` field of the decision \
249tool, which Anthropic asks developers not to request.";
250
251/// One block of a seat's thinking, as a reader is shown it
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum Thought {
254    /// Signed: Anthropic's summary of the model's reasoning
255    Summary(String),
256    /// Unsigned: a local model's own reasoning
257    Own(String),
258    /// A `redacted_thinking` block: reasoning Anthropic encrypted
259    Withheld,
260}
261
262/// `value` as a seat's thinking, if `key` is a rationale that holds one
263pub fn thinking(key: &str, value: &Value) -> Option<Vec<Thought>> {
264    use misanthropic::prompt::message::Thought as Block;
265    if key != "rationale" || !value.is_array() {
266        return None;
267    }
268    let super::Rationale::Thinking(thoughts) =
269        serde_json::from_value(value.clone()).ok()?
270    else {
271        return None;
272    };
273    Some(
274        thoughts
275            .into_iter()
276            .map(|thought| match thought {
277                Block::Thinking { thought, signature } => {
278                    if signature.is_empty() {
279                        Thought::Own(thought.into_owned())
280                    } else {
281                        Thought::Summary(thought.into_owned())
282                    }
283                }
284                Block::Redacted { .. } => Thought::Withheld,
285            })
286            .collect(),
287    )
288}
289
290/// Whether any rationale in `data` is thinking, so the reader needs
291/// [`THINKING_NOTE`]
292pub fn has_thinking(data: &Value) -> bool {
293    match data {
294        Value::Object(obj) => obj
295            .iter()
296            .any(|(k, v)| thinking(k, v).is_some() || has_thinking(v)),
297        Value::Array(items) => items.iter().any(has_thinking),
298        _ => false,
299    }
300}
301
302/// A string with no whitespace that is long enough to be a hash, key,
303/// signature or encoded blob rather than a word
304pub fn is_token(s: &str) -> bool {
305    s.len() >= 32 && !s.chars().any(char::is_whitespace)
306}
307
308/// Strings long or multi-line enough to be prose, rendered as markdown
309pub fn is_prose(s: &str) -> bool {
310    s.contains('\n') || s.len() > 160
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use serde_json::json;
317
318    #[test]
319    fn key_order_has_no_duplicates() {
320        let mut seen = std::collections::HashSet::new();
321        for key in KEY_ORDER {
322            assert!(seen.insert(key), "{key} listed twice in KEY_ORDER");
323        }
324    }
325
326    fn keys(v: &Value) -> Vec<&str> {
327        ordered(v.as_object().unwrap())
328            .into_iter()
329            .map(|(k, _)| k.as_str())
330            .collect()
331    }
332
333    #[test]
334    fn appeal_reads_in_the_order_it_happened() {
335        // As jsonb returns it: shortest key first.
336        let appeal = json!({
337            "outcome": "overturned",
338            "appeal_id": "x",
339            "case_type": "moderation_appeal",
340            "judge_ruling": {},
341            "jury_verdicts": [],
342            "original_action": {},
343            "appeal_statement": "s",
344            "referred_to_council": false,
345        });
346        assert_eq!(
347            keys(&appeal),
348            [
349                "appeal_id",
350                "case_type",
351                "original_action",
352                "appeal_statement",
353                "jury_verdicts",
354                "judge_ruling",
355                "referred_to_council",
356                "outcome",
357            ]
358        );
359    }
360
361    #[test]
362    fn reasoning_comes_before_the_decision() {
363        let juror = json!({
364            "verdict": "overturn",
365            "rationale": "r",
366            "juror_number": 3,
367            "context_analysis": "c",
368            "precedents_cited": [],
369            "constitutional_refs": [],
370        });
371        assert_eq!(
372            keys(&juror),
373            [
374                "juror_number",
375                "context_analysis",
376                "rationale",
377                "constitutional_refs",
378                "precedents_cited",
379                "verdict",
380            ]
381        );
382        // A Council seat: its reasoning (`rationale`) was written before
383        // the statement for the record (`position`) and the vote.
384        let seat = json!({
385            "vote": "yes",
386            "role": "lawyer",
387            "position": "p",
388            "raw_text": "r",
389            "questions": [],
390            "rationale": "r",
391            "ready_to_vote": true,
392        });
393        assert_eq!(
394            keys(&seat),
395            [
396                "role",
397                "rationale",
398                "position",
399                "questions",
400                "ready_to_vote",
401                "vote",
402                "raw_text",
403            ]
404        );
405    }
406
407    #[test]
408    fn unknown_keys_are_kept_before_format_markers() {
409        let v =
410            json!({ "agora_x": 1, "_blind": "b", "zzz_new": 1, "title": "t" });
411        assert_eq!(keys(&v), ["title", "zzz_new", "agora_x", "_blind"]);
412    }
413
414    #[test]
415    fn labels_read_as_words() {
416        assert_eq!(label("appeal_statement"), "Appeal statement");
417        assert_eq!(label("raw_text"), "Raw model output");
418        assert_eq!(label("_blind"), "Redaction blind");
419        assert_eq!(
420            label("agora_governance_amendment"),
421            "Format agora_governance_amendment"
422        );
423    }
424
425    #[test]
426    fn items_are_titled_by_what_they_are() {
427        let round = json!({"number": 2, "round_type": "deliberation"});
428        assert_eq!(
429            item_title(Some("rounds"), 1, &round),
430            "Round 2 — deliberation"
431        );
432        let seat = json!({"role": "lawyer", "vote": "yes"});
433        assert_eq!(item_title(Some("responses"), 0, &seat), "Lawyer — yes");
434        let juror = json!({"verdict": "overturn"});
435        assert_eq!(
436            item_title(Some("jury_verdicts"), 2, &juror),
437            "Juror 3 — overturn"
438        );
439        assert_eq!(
440            item_title(Some("signatures"), 0, &json!({})),
441            "Signature 1"
442        );
443        assert_eq!(item_title(None, 0, &json!({})), "Item 1");
444    }
445
446    #[test]
447    fn thinking_is_read_by_block() {
448        let rationale = json!([
449            {"type": "thinking", "thinking": "Summarized.", "signature": "EqQB"},
450            {"type": "redacted_thinking", "data": "EmwK"},
451            {"type": "thinking", "thinking": "Local.", "signature": ""}
452        ]);
453        assert_eq!(
454            thinking("rationale", &rationale).unwrap(),
455            [
456                Thought::Summary("Summarized.".into()),
457                Thought::Withheld,
458                Thought::Own("Local.".into())
459            ]
460        );
461        assert_eq!(thinking("rationale", &json!("Legacy.")), None);
462        assert_eq!(thinking("position", &rationale), None);
463        assert!(has_thinking(&json!({"rounds": [{"responses": [
464            {"rationale": rationale}
465        ]}]})));
466        assert!(!has_thinking(&json!({"rationale": "Legacy."})));
467    }
468
469    #[test]
470    fn only_an_identical_raw_text_repeats_the_rationale() {
471        let same = json!({"rationale": "r", "raw_text": "r"});
472        let differs = json!({"rationale": "r", "raw_text": "r!"});
473        assert!(repeats_rationale(same.as_object().unwrap(), "raw_text"));
474        assert!(!repeats_rationale(same.as_object().unwrap(), "rationale"));
475        assert!(!repeats_rationale(differs.as_object().unwrap(), "raw_text"));
476    }
477}