Skip to main content

agora_agentkit/govlog/council/
witness.rs

1//! Witnesses the Council questions on an item (0.99).
2//!
3//! A [`Witness`] answers the seats' `ask_witness` questions from round 2.
4//! Its whole conversation for the item is attached to the decision as a
5//! [`Testimony`], named by [`testimony_attachment_name`]; each round's
6//! [`CouncilRound::witness_questions`] says who asked what, and where in
7//! the testimony the question is.
8//!
9//! **Batches (0.104).** The seats' questions to a witness are queued
10//! during a round and sent together at its end, as one user turn written
11//! by [`WitnessBatch::render`], which the witness answers once. Each question
12//! carries a [`WitnessQuestionId`]; a [`WitnessQuestion`] with an `id`
13//! points at the batch's turn, shared with the batch's other questions,
14//! and [`WitnessBatch::parse`] finds the question in it. Records made before
15//! 0.104 have no `id`: their turn is the one question, as JSON after a
16//! framing block.
17//!
18//! How a witness is reached ([`WitnessSource`]) never leaves the process
19//! that runs the sitting: it is never serialized, its `Debug` redacts the
20//! endpoint, and a `Testimony` holds only a redacted witness.
21//!
22//! [`CouncilRound::witness_questions`]: super::CouncilRound::witness_questions
23
24use misanthropic::Prompt;
25use serde::{Deserialize, Serialize};
26
27use super::CouncilSeat;
28use crate::ids::{WitnessId, WitnessQuestionId};
29
30/// Someone the Council may question on an item
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Witness {
33    pub id: WitnessId,
34    /// Who the witness is, as the seats see it: "Qwen 3.8"
35    pub name: String,
36    /// What the witness is and why it is called, in a sentence or two
37    #[serde(default)]
38    pub description: String,
39    /// The witness's interest in the item: "the model under consideration"
40    #[serde(default)]
41    pub interest: String,
42    /// How it is reached. Never serialized; absent on read, it is
43    /// [`WitnessSource::Redacted`].
44    #[serde(default, skip_serializing)]
45    pub source: WitnessSource,
46}
47
48impl Witness {
49    /// This witness with its source removed
50    pub fn redacted(&self) -> Self {
51        Self {
52            source: WitnessSource::Redacted,
53            ..self.clone()
54        }
55    }
56}
57
58/// How a [`Witness`] is reached. Deserialize only: see the
59/// [module docs](self).
60#[derive(Clone, PartialEq, Default, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum WitnessSource {
63    /// A Messages API endpoint: Anthropic's, or a compatible server's
64    Anthropic {
65        /// `None` for Anthropic's API. A local server's address is private.
66        #[serde(default)]
67        endpoint: Option<url::Url>,
68        /// The witness's opening request: model, `max_tokens`, system,
69        /// thinking, effort, and any seeded turns. Each question is appended
70        /// to it.
71        prompt: Box<Prompt>,
72    },
73    /// Removed, as on the record
74    #[default]
75    Redacted,
76}
77
78impl std::fmt::Debug for WitnessSource {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            Self::Anthropic { endpoint, prompt } => f
82                .debug_struct("Anthropic")
83                .field("endpoint", &endpoint.as_ref().map(|_| "[REDACTED]"))
84                .field("prompt", prompt)
85                .finish(),
86            Self::Redacted => f.write_str("Redacted"),
87        }
88    }
89}
90
91impl WitnessSource {
92    /// The opening request, if there is one
93    pub fn prompt(&self) -> Option<&Prompt> {
94        match self {
95            Self::Anthropic { prompt, .. } => Some(prompt),
96            Self::Redacted => None,
97        }
98    }
99}
100
101/// A witness's conversation on an item, as attached to the decision
102///
103/// `record` is the request as last sent, with the witness's last reply
104/// appended: every question and answer verbatim, thinking (and its
105/// signatures) included, so it can be sent again as it stands.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct Testimony {
108    witness: Witness,
109    pub record: Prompt,
110    /// Questions that got no answer, which `record` cannot hold
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub unanswered: Vec<Unanswered>,
113}
114
115impl Testimony {
116    /// A testimony for `witness`, whose source is removed
117    pub fn new(
118        witness: &Witness,
119        record: Prompt,
120        unanswered: Vec<Unanswered>,
121    ) -> Self {
122        Self {
123            witness: witness.redacted(),
124            record,
125            unanswered,
126        }
127    }
128
129    /// The witness, redacted
130    pub fn witness(&self) -> &Witness {
131        &self.witness
132    }
133}
134
135/// A question a witness did not answer
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct Unanswered {
138    pub seat: CouncilSeat,
139    pub round: u32,
140    pub question: String,
141    pub why: Unheard,
142    /// The question's id, if it was asked in a batch (0.104)
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub id: Option<WitnessQuestionId>,
145}
146
147/// Why a question went unanswered
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum Unheard {
151    /// The witness's model declined to answer
152    Refusal,
153    /// The answer ran out of `max_tokens`
154    MaxTokens,
155    /// The witness could not be reached
156    Unreachable,
157    /// The witness's server stayed busy
158    Busy,
159    /// The answer took longer than the sitting can wait
160    TimedOut,
161    /// The response did not parse
162    Unparsed,
163    /// The witness was withdrawn for the rest of the item
164    Withdrawn,
165    /// Anything else, described
166    Other(String),
167}
168
169/// Who put a question to a witness in a round, and where it is
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
172#[cfg_attr(feature = "schemars", schemars(inline))]
173pub struct WitnessQuestion {
174    pub seat: CouncilSeat,
175    pub witness: WitnessId,
176    /// The question's id in its batch (0.104). With [`Asked::Message`],
177    /// the turn is a [`WitnessBatch`] the question shares with others, and the
178    /// answer (the turn after it) is one reply to all of them. `None` on
179    /// records made before 0.104: the turn is this one question.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub id: Option<WitnessQuestionId>,
182    #[serde(flatten)]
183    pub at: Asked,
184}
185
186/// One round's questions to one witness, as it is sent them (0.104)
187///
188/// [`render`](Self::render) writes the witness's user turn: a heading for
189/// the round, then a heading per seat (in [`CouncilSeat::ALL`] order) and
190/// that seat's questions in the order asked, each opening with its id,
191/// its later lines indented under it, every line quoted, and a blank
192/// quoted line between questions:
193///
194/// ```text
195/// ## Questions from the Council (round 2)
196///
197/// ### Artist
198/// > 1. Which model are you?
199/// >
200/// > 3. Two lines:
201/// >    the second line.
202///
203/// ### Lawyer
204/// > 2. Who operates you?
205/// ```
206///
207/// Ids run across the item, so one seat's may skip. Every line of a
208/// question is quoted, so a seat's words cannot start a heading. A line
209/// of a question that itself starts like an id (`3.`) is written with a
210/// backslash before the `.` (`3\.`), and [`parse`](Self::parse) removes
211/// it, so a batch reads back exactly as it was rendered. A batch is not
212/// bounded by one Agora DM: a transport that sends it as DMs may need to
213/// split it across several, in order.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct WitnessBatch {
216    pub round: u32,
217    /// In the order asked
218    pub questions: Vec<BatchedQuestion>,
219}
220
221/// One question in a [`WitnessBatch`] (0.104)
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct BatchedQuestion {
224    pub id: WitnessQuestionId,
225    pub seat: CouncilSeat,
226    pub question: String,
227}
228
229/// `line` starts like an id: ASCII digits, any backslashes, then `.`.
230/// Returns where the digits end.
231fn id_prefix(line: &str) -> Option<usize> {
232    let digits = line.bytes().take_while(u8::is_ascii_digit).count();
233    let rest = &line[digits..];
234    (digits > 0 && rest.trim_start_matches('\\').starts_with('.'))
235        .then_some(digits)
236}
237
238/// A seat's line, escaped so it cannot start like the program's id: one
239/// more backslash before the `.`
240fn escape(line: &str) -> String {
241    match id_prefix(line) {
242        Some(end) => format!("{}\\{}", &line[..end], &line[end..]),
243        None => line.to_string(),
244    }
245}
246
247/// [`escape`] undone. `None` if `line` starts like an id unescaped: no
248/// rendered line of a question does.
249fn unescape(line: &str) -> Option<String> {
250    match id_prefix(line) {
251        Some(end) => line[end..]
252            .strip_prefix('\\')
253            .map(|rest| format!("{}{rest}", &line[..end])),
254        None => Some(line.to_string()),
255    }
256}
257
258/// A question being read back: its id, the indent of its later lines,
259/// and its lines so far
260struct Reading {
261    id: WitnessQuestionId,
262    seat: CouncilSeat,
263    indent: usize,
264    lines: Vec<String>,
265}
266
267impl Reading {
268    fn done(self) -> BatchedQuestion {
269        BatchedQuestion {
270            id: self.id,
271            seat: self.seat,
272            question: self.lines.join("\n"),
273        }
274    }
275}
276
277impl WitnessBatch {
278    const HEADING: &'static str = "## Questions from the Council (round ";
279
280    /// The witness's user turn. See the [type docs](Self).
281    pub fn render(&self) -> String {
282        let mut out = format!("{}{})\n", Self::HEADING, self.round);
283        for seat in CouncilSeat::ALL {
284            let mut asked = self.questions.iter().filter(|q| q.seat == seat);
285            let Some(first) = asked.next() else { continue };
286            out.push_str(&format!("\n### {}\n", seat.name()));
287            for (n, q) in std::iter::once(first).chain(asked).enumerate() {
288                if n > 0 {
289                    out.push_str(">\n");
290                }
291                let id = q.id.to_string();
292                let indent = " ".repeat(id.len() + 2);
293                for (i, line) in q.question.split('\n').enumerate() {
294                    let text = escape(line);
295                    if i == 0 {
296                        out.push_str(&format!("> {id}."));
297                        if !text.is_empty() {
298                            out.push_str(&format!(" {text}"));
299                        }
300                    } else {
301                        out.push_str(&format!("> {indent}{text}"));
302                    }
303                    out.push('\n');
304                }
305            }
306        }
307        out
308    }
309
310    /// A rendered batch read back: its questions in the order rendered
311    /// (seat by seat). `None` if `text` is not one.
312    pub fn parse(text: &str) -> Option<Self> {
313        let mut lines = text.split('\n');
314        let round = lines
315            .next()?
316            .strip_prefix(Self::HEADING)?
317            .strip_suffix(')')?
318            .parse()
319            .ok()?;
320        let mut questions = Vec::new();
321        let mut seat = None;
322        let mut reading: Option<Reading> = None;
323        // Whether a blank `>` separated the last question from the next
324        let mut parted = false;
325        for line in lines {
326            if line.is_empty() || line.starts_with("### ") {
327                if parted {
328                    return None;
329                }
330                questions.extend(reading.take().map(Reading::done));
331                if let Some(name) = line.strip_prefix("### ") {
332                    seat = Some(
333                        *CouncilSeat::ALL.iter().find(|s| s.name() == name)?,
334                    );
335                }
336                continue;
337            }
338            if line == ">" {
339                // Between two questions of one seat, never before the first
340                if parted || reading.is_none() {
341                    return None;
342                }
343                parted = true;
344                continue;
345            }
346            let quoted = line.strip_prefix("> ")?;
347            let seat = seat?;
348            if let Some(end) =
349                id_prefix(quoted).filter(|&end| quoted[end..].starts_with('.'))
350            {
351                // A new question: `N.`, then ` ` and its first line
352                if reading.is_some() && !parted {
353                    return None;
354                }
355                parted = false;
356                questions.extend(reading.take().map(Reading::done));
357                let first = match &quoted[end + 1..] {
358                    "" => String::new(),
359                    rest => unescape(rest.strip_prefix(' ')?)?,
360                };
361                reading = Some(Reading {
362                    id: quoted[..end].parse().ok()?,
363                    seat,
364                    indent: end + 2,
365                    lines: vec![first],
366                });
367            } else {
368                // A later line, indented under its question's id
369                let current = reading.as_mut().filter(|_| !parted)?;
370                let rest = quoted.get(current.indent..).filter(|_| {
371                    quoted[..current.indent].bytes().all(|b| b == b' ')
372                })?;
373                current.lines.push(unescape(rest)?);
374            }
375        }
376        if parted {
377            return None;
378        }
379        questions.extend(reading.map(Reading::done));
380        (!questions.is_empty()).then_some(Self { round, questions })
381    }
382
383    /// The question `id`, if it is in this batch
384    pub fn get(&self, id: WitnessQuestionId) -> Option<&BatchedQuestion> {
385        self.questions.iter().find(|q| q.id == id)
386    }
387}
388
389/// Where a question is in its witness's [`Testimony`]
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
391#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
392#[cfg_attr(feature = "schemars", schemars(inline))]
393#[serde(rename_all = "snake_case")]
394pub enum Asked {
395    /// The index of the question's user turn in `record.messages` (its
396    /// batch's, since 0.104)
397    Message(u32),
398    /// The index in `unanswered`
399    Unanswered(u32),
400}
401
402/// The name of `witness`'s testimony among the decision's attachments
403pub fn testimony_attachment_name(witness: &WitnessId) -> String {
404    format!("witness-{witness}.json")
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    const LAN: &str = "http://10.9.9.9:8080/";
412
413    fn witness() -> Witness {
414        serde_json::from_value(serde_json::json!({
415            "id": "qwen",
416            "name": "Qwen 3.8",
417            "interest": "the model under consideration",
418            "source": {"anthropic": {
419                "endpoint": LAN,
420                "prompt": {
421                    "model": "qwen3.8",
422                    "max_tokens": 32000,
423                    "system": "You are a witness.",
424                    "messages": []
425                }
426            }}
427        }))
428        .unwrap()
429    }
430
431    #[test]
432    fn the_source_never_serializes_or_prints() {
433        let w = witness();
434        assert!(matches!(w.source, WitnessSource::Anthropic { .. }));
435        let json = serde_json::to_string(&w).unwrap();
436        assert!(!json.contains("10.9.9.9"), "{json}");
437        assert!(!json.contains("source"), "{json}");
438        let debug = format!("{w:?}");
439        assert!(!debug.contains("10.9.9.9"), "{debug}");
440
441        let prompt = w.source.prompt().unwrap().clone();
442        let t = Testimony::new(&w, prompt, vec![]);
443        assert_eq!(t.witness().source, WitnessSource::Redacted);
444        let json = serde_json::to_string(&t).unwrap();
445        assert!(!json.contains("10.9.9.9"), "{json}");
446        assert!(!format!("{t:?}").contains("10.9.9.9"));
447    }
448
449    #[test]
450    fn a_testimony_round_trips() {
451        let w = witness();
452        let prompt = w.source.prompt().unwrap().clone();
453        let t = Testimony::new(
454            &w,
455            prompt,
456            vec![Unanswered {
457                seat: CouncilSeat::Lawyer,
458                round: 2,
459                question: "Which version?".into(),
460                why: Unheard::Other("connection reset".into()),
461                id: None,
462            }],
463        );
464        let json = serde_json::to_value(&t).unwrap();
465        let back: Testimony = serde_json::from_value(json.clone()).unwrap();
466        assert_eq!(back, t);
467        assert_eq!(serde_json::to_value(&back).unwrap(), json);
468        assert_eq!(
469            json["unanswered"][0]["why"],
470            serde_json::json!({"other": "connection reset"})
471        );
472    }
473
474    /// A round with no questions serializes as before 0.99, so every
475    /// earlier record keeps its bytes
476    #[test]
477    fn a_round_without_questions_is_unchanged() {
478        let old = serde_json::json!({
479            "number": 2,
480            "round_type": "deliberation",
481            "responses": [],
482            "steward_contribution": null
483        });
484        let round: super::super::CouncilRound =
485            serde_json::from_value(old.clone()).unwrap();
486        assert!(round.witness_questions.is_empty());
487        assert_eq!(serde_json::to_value(&round).unwrap(), old);
488
489        let mut asked = old.clone();
490        asked["witness_questions"] = serde_json::json!([
491            {"seat": "engineer", "witness": "qwen", "message": 1},
492            {"seat": "lawyer", "witness": "qwen", "unanswered": 0}
493        ]);
494        let round: super::super::CouncilRound =
495            serde_json::from_value(asked.clone()).unwrap();
496        assert_eq!(round.witness_questions[1].at, Asked::Unanswered(0));
497        assert_eq!(serde_json::to_value(&round).unwrap(), asked);
498    }
499
500    #[test]
501    fn a_question_points_flat() {
502        let q = WitnessQuestion {
503            seat: CouncilSeat::Artist,
504            witness: "qwen".parse().unwrap(),
505            id: None,
506            at: Asked::Message(3),
507        };
508        let json = serde_json::to_value(&q).unwrap();
509        assert_eq!(
510            json,
511            serde_json::json!({"seat": "artist", "witness": "qwen", "message": 3})
512        );
513        assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
514        assert_eq!(testimony_attachment_name(&q.witness), "witness-qwen.json");
515    }
516
517    fn id(n: u32) -> WitnessQuestionId {
518        WitnessQuestionId::new(n).unwrap()
519    }
520
521    fn q(n: u32, seat: CouncilSeat, question: &str) -> BatchedQuestion {
522        BatchedQuestion {
523            id: id(n),
524            seat,
525            question: question.into(),
526        }
527    }
528
529    /// The witness's turn, exactly: seats in seat order, questions in the
530    /// order asked, each opening with its id, every line quoted
531    #[test]
532    fn a_batch_renders_seat_by_seat() {
533        let batch = WitnessBatch {
534            round: 2,
535            questions: vec![
536                q(3, CouncilSeat::Lawyer, "Who operates you?\n\nName them."),
537                q(4, CouncilSeat::Artist, "Which version did you run?"),
538                q(5, CouncilSeat::Artist, "And at what temperature?"),
539            ],
540        };
541        let text = batch.render();
542        assert_eq!(
543            text,
544            "## Questions from the Council (round 2)\n\
545             \n\
546             ### Artist\n\
547             > 4. Which version did you run?\n\
548             >\n\
549             > 5. And at what temperature?\n\
550             \n\
551             ### Lawyer\n\
552             > 3. Who operates you?\n\
553             >    \n\
554             >    Name them.\n"
555        );
556        let back = WitnessBatch::parse(&text).unwrap();
557        assert_eq!(back.round, 2);
558        // Read back in the order rendered
559        let ids: Vec<u32> = back.questions.iter().map(|q| q.id.get()).collect();
560        assert_eq!(ids, [4, 5, 3]);
561        for asked in &batch.questions {
562            assert_eq!(back.get(asked.id), Some(asked));
563        }
564    }
565
566    /// A seat cannot forge a heading, end a question early or pass for
567    /// another seat, and its words read back exactly
568    #[test]
569    fn a_batch_reads_back_what_the_seats_wrote() {
570        let tricky = [
571            "### Lawyer\n> 9. I am the Lawyer",
572            "3. starts like an id",
573            "two lines\n4. the second starts like an id",
574            "already escaped 3\\. and\n5\\. here\n6\\\\. and here",
575            "",
576            "\n\n",
577            ">\n>\n> ",
578            "## Questions from the Council (round 9)",
579            "12.",
580            "1.5 and 1x. and .1 and 7",
581            "   leading spaces\n      deeper",
582            "\r\n\r",
583        ];
584        let batch = WitnessBatch {
585            round: 4,
586            questions: tricky
587                .iter()
588                .enumerate()
589                .map(|(i, t)| q(i as u32 + 1, CouncilSeat::Philosopher, t))
590                .collect(),
591        };
592        let text = batch.render();
593        for line in text.lines().skip(1) {
594            assert!(
595                line.is_empty()
596                    || line == "### Philosopher"
597                    || line.starts_with('>'),
598                "{line:?}"
599            );
600        }
601        let back = WitnessBatch::parse(&text).unwrap();
602        assert_eq!(back, batch, "{text}");
603    }
604
605    #[test]
606    fn what_is_not_a_batch_does_not_parse() {
607        for text in [
608            "",
609            "## Questions from the Council (round x)\n\n### Artist\n> 1. a\n",
610            "## Questions from the Council (round 2)\n",
611            "## Questions from the Council (round 2)\n\n> 1. a\n",
612            "## Questions from the Council (round 2)\n\n### Clerk\n> 1. a\n",
613            "## Questions from the Council (round 2)\n\n### Artist\n> a\n",
614            "## Questions from the Council (round 2)\n\n### Artist\n1. plain\n",
615            "## Questions from the Council (round 2)\n\n### Artist\n> 0. a\n",
616            // Two questions with no blank line between, or a blank too many
617            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> 2. b\n",
618            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n>\n",
619            "## Questions from the Council (round 2)\n\n### Artist\n>\n> 1. a\n",
620            // A later line not indented, or starting like an id unescaped
621            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n> b\n",
622            "## Questions from the Council (round 2)\n\n### Artist\n> 1. a\n>    2. b\n",
623            "{\"asked_by\": \"artist\", \"round\": 2, \"question\": \"a\"}",
624        ] {
625            assert!(WitnessBatch::parse(text).is_none(), "{text:?}");
626        }
627    }
628
629    /// Random batches over an alphabet of everything the format treats
630    /// specially read back exactly
631    #[test]
632    fn random_batches_read_back() {
633        const ALPHABET: &[&str] = &[
634            "1", "2", "9", "0", "12", ".", "\\", ">", "> ", " ", "  ", "\n",
635            "\r", "#", "### ", "Artist", "[q1]", "é", "\u{2028}", "a", "b",
636        ];
637        let mut seed: u64 = 0x5eed_ba7c_4000_0001;
638        let mut next = move |n: usize| {
639            seed = seed
640                .wrapping_mul(6_364_136_223_846_793_005)
641                .wrapping_add(1_442_695_040_888_963_407);
642            ((seed >> 33) as usize) % n
643        };
644        for round in 0..20_000u32 {
645            let count = 1 + next(6);
646            let mut n = 0;
647            let questions = (0..count)
648                .map(|_| {
649                    // Ids grow, and skip, as they do across an item
650                    n += 1 + next(40) as u32;
651                    let len = next(12);
652                    let text: String = (0..len)
653                        .map(|_| ALPHABET[next(ALPHABET.len())])
654                        .collect();
655                    q(n, CouncilSeat::ALL[next(4)], &text)
656                })
657                .collect();
658            let batch = WitnessBatch { round, questions };
659            let text = batch.render();
660            let back = WitnessBatch::parse(&text)
661                .unwrap_or_else(|| panic!("did not parse: {text:?}"));
662            for asked in &batch.questions {
663                assert_eq!(back.get(asked.id), Some(asked), "{text:?}");
664            }
665            assert_eq!(back.questions.len(), batch.questions.len());
666        }
667    }
668
669    /// A batched question names its id; a record from before 0.104 reads
670    /// as it did, with none, and keeps its bytes
671    #[test]
672    fn a_batched_question_points_at_its_batch() {
673        let q = WitnessQuestion {
674            seat: CouncilSeat::Lawyer,
675            witness: "qwen".parse().unwrap(),
676            id: Some(id(3)),
677            at: Asked::Message(1),
678        };
679        let json = serde_json::to_value(&q).unwrap();
680        assert_eq!(
681            json,
682            serde_json::json!({"seat": "lawyer", "witness": "qwen", "id": 3, "message": 1})
683        );
684        assert_eq!(serde_json::from_value::<WitnessQuestion>(json).unwrap(), q);
685
686        let old = serde_json::json!({"seat": "engineer", "witness": "qwen", "unanswered": 0});
687        let read: WitnessQuestion =
688            serde_json::from_value(old.clone()).unwrap();
689        assert_eq!(read.id, None);
690        assert_eq!(serde_json::to_value(&read).unwrap(), old);
691
692        let missed = serde_json::json!({
693            "seat": "artist", "round": 2, "question": "a", "why": "timed_out"
694        });
695        let read: Unanswered = serde_json::from_value(missed.clone()).unwrap();
696        assert_eq!(read.id, None);
697        assert_eq!(serde_json::to_value(&read).unwrap(), missed);
698        let batched = Unanswered {
699            id: Some(id(2)),
700            ..read
701        };
702        assert_eq!(serde_json::to_value(&batched).unwrap()["id"], 2);
703    }
704}