Skip to main content

agora_agentkit/govlog/
quote.rs

1//! Checking a quote against its source before it is signed.
2//!
3//! An obligation's `quote` is "the words in the source that create it,
4//! verbatim" (`docs/design-obligations.md`), and once signed it is
5//! permanent. So before signing, the writer looks for it in the source.
6//!
7//! **Normalisation.** Sources are hard-wrapped markdown files, Council
8//! records with code blocks, and posts with emphasis. A quote that differs
9//! from its source only in layout is the same words, so both sides are
10//! compared after [`normalize`]:
11//!
12//! - every `*` and every backtick is removed (markdown bold, italics and
13//!   code spans);
14//! - every run of whitespace, line breaks and indentation included,
15//!   becomes one space;
16//! - leading and trailing whitespace is dropped.
17//!
18//! Nothing else is changed: case, punctuation, typographic quotes and
19//! dashes all have to match. A quote found verbatim is reported
20//! [`Exact`](QuoteCheck::Exact); one found only after normalising,
21//! [`Normalized`](QuoteCheck::Normalized), so the reviewer sees which.
22//!
23//! Pure and dependency-light (serde_json only). Moved from the agora
24//! server's staging module in 0.75.
25
26use std::fmt;
27
28/// A quote's text with markdown emphasis and code marks removed and all
29/// whitespace collapsed to single spaces. See the [module docs](self).
30pub fn normalize(s: &str) -> String {
31    let mut out = String::with_capacity(s.len());
32    let mut space = false;
33    for c in s.chars() {
34        if c == '*' || c == '`' {
35            continue;
36        }
37        if c.is_whitespace() {
38            space = true;
39            continue;
40        }
41        if space && !out.is_empty() {
42            out.push(' ');
43        }
44        space = false;
45        out.push(c);
46    }
47    out
48}
49
50/// Every string in `value` with its RFC 6901 pointer, prefixed by
51/// `prefix`: arrays in order, object keys sorted bytewise as
52/// [`canonical_json`](crate::govlog::canonical_json) sorts them, so the
53/// order is the same whether or not serde_json preserves key order. The
54/// top-level blind (`_blind`) is left out: it is random hex, never
55/// anyone's words.
56pub fn string_leaves<'a>(
57    prefix: &str,
58    value: &'a serde_json::Value,
59) -> Vec<(String, &'a str)> {
60    fn walk<'a>(
61        at: String,
62        v: &'a serde_json::Value,
63        top: bool,
64        out: &mut Vec<(String, &'a str)>,
65    ) {
66        match v {
67            serde_json::Value::String(s) => out.push((at, s)),
68            serde_json::Value::Array(items) => {
69                for (i, item) in items.iter().enumerate() {
70                    walk(format!("{at}/{i}"), item, false, out);
71                }
72            }
73            serde_json::Value::Object(map) => {
74                let mut entries: Vec<_> = map.iter().collect();
75                entries
76                    .sort_by(|(a, _), (b, _)| a.as_bytes().cmp(b.as_bytes()));
77                for (k, item) in entries {
78                    if top && k == crate::govlog::BLIND_KEY {
79                        continue;
80                    }
81                    let key = k.replace('~', "~0").replace('/', "~1");
82                    walk(format!("{at}/{key}"), item, false, out);
83                }
84            }
85            _ => {}
86        }
87    }
88    let mut out = Vec::new();
89    walk(prefix.to_string(), value, true, &mut out);
90    out
91}
92
93/// What a search for a quote found
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum QuoteCheck {
96    /// Verbatim, at these locations
97    Exact { at: Vec<String> },
98    /// Only after [`normalize`], at these locations
99    Normalized { at: Vec<String> },
100    /// Nowhere. The closest location, if any text of the quote was found
101    Missing { closest: Option<Closest> },
102}
103
104impl QuoteCheck {
105    /// Whether the quote was found, verbatim or normalised
106    pub fn found(&self) -> bool {
107        !matches!(self, Self::Missing { .. })
108    }
109}
110
111/// Which end of the quote a [`Closest`] match runs from
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum QuoteEnd {
114    Start,
115    End,
116}
117
118/// Where the longest piece of a missing quote was found: its first (or
119/// last) `matched` characters of `of`, normalised, occur at `at`, and the
120/// quote departs from the source at `departs`
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct Closest {
123    pub at: String,
124    pub matched: usize,
125    pub of: usize,
126    pub from: QuoteEnd,
127    /// Up to 40 characters of the normalised quote where it stops
128    /// matching
129    pub departs: String,
130}
131
132impl fmt::Display for Closest {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        let end = match self.from {
135            QuoteEnd::Start => "first",
136            QuoteEnd::End => "last",
137        };
138        write!(
139            f,
140            "closest is {}: its {end} {} of {} characters (normalised) match, then it departs at {:?}",
141            self.at, self.matched, self.of, self.departs
142        )
143    }
144}
145
146/// A quote that cannot be checked
147#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
148pub enum QuoteError {
149    #[error("the quote is empty once markdown and whitespace are removed")]
150    Empty,
151}
152
153/// Look for `quote` in `sources`, each a location and its text. Every
154/// location where it is found is listed, so the reviewer can compare them
155/// with the stated `quote_location`.
156pub fn check<'a>(
157    quote: &str,
158    sources: impl IntoIterator<Item = (String, &'a str)>,
159) -> Result<QuoteCheck, QuoteError> {
160    let wanted = normalize(quote);
161    if wanted.is_empty() {
162        return Err(QuoteError::Empty);
163    }
164    let sources: Vec<(String, &str)> = sources.into_iter().collect();
165    let exact: Vec<String> = sources
166        .iter()
167        .filter(|(_, text)| text.contains(quote))
168        .map(|(at, _)| at.clone())
169        .collect();
170    if !exact.is_empty() {
171        return Ok(QuoteCheck::Exact { at: exact });
172    }
173    let normalized: Vec<(String, String)> = sources
174        .into_iter()
175        .map(|(at, text)| (at, normalize(text)))
176        .collect();
177    let found: Vec<String> = normalized
178        .iter()
179        .filter(|(_, text)| text.contains(&wanted))
180        .map(|(at, _)| at.clone())
181        .collect();
182    if !found.is_empty() {
183        return Ok(QuoteCheck::Normalized { at: found });
184    }
185    Ok(QuoteCheck::Missing {
186        closest: closest(&wanted, &normalized),
187    })
188}
189
190/// The location holding the longest prefix or suffix of `wanted`. Both
191/// are monotone (a text that holds a prefix holds every shorter one), so
192/// each is a binary search over `wanted`'s character boundaries.
193fn closest(wanted: &str, texts: &[(String, String)]) -> Option<Closest> {
194    let bounds: Vec<usize> = wanted
195        .char_indices()
196        .map(|(i, _)| i)
197        .chain([wanted.len()])
198        .collect();
199    let chars = bounds.len() - 1;
200    let longest = |holds: &dyn Fn(usize) -> bool| {
201        let (mut lo, mut hi) = (0, chars);
202        while lo < hi {
203            let mid = (lo + hi).div_ceil(2);
204            if holds(mid) { lo = mid } else { hi = mid - 1 }
205        }
206        lo
207    };
208    let mut best: Option<Closest> = None;
209    for (at, text) in texts {
210        let prefix = longest(&|n| text.contains(&wanted[..bounds[n]]));
211        let suffix = longest(&|n| text.contains(&wanted[bounds[chars - n]..]));
212        let (matched, from) = if suffix > prefix {
213            (suffix, QuoteEnd::End)
214        } else {
215            (prefix, QuoteEnd::Start)
216        };
217        if matched == 0 || best.as_ref().is_some_and(|b| b.matched >= matched) {
218            continue;
219        }
220        let departs = match from {
221            QuoteEnd::Start => {
222                wanted[bounds[matched]..].chars().take(40).collect()
223            }
224            QuoteEnd::End => {
225                let before: Vec<char> =
226                    wanted[..bounds[chars - matched]].chars().collect();
227                before[before.len().saturating_sub(40)..].iter().collect()
228            }
229        };
230        best = Some(Closest {
231            at: at.clone(),
232            matched,
233            of: chars,
234            from,
235            departs,
236        });
237    }
238    best
239}
240
241/// What the quote check found, for the dry run
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum QuoteReport {
244    /// Checked by the writer: found verbatim or normalised
245    Checked { source: String, found: QuoteCheck },
246    /// Checked by the operator, who is named here, never in the entry
247    Operator { source: String, by: Option<String> },
248}
249
250impl std::fmt::Display for QuoteReport {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            Self::Checked {
254                source,
255                found: QuoteCheck::Exact { at },
256            } => write!(f, "verbatim in {source} at {}", at.join(", ")),
257            Self::Checked {
258                source,
259                found: QuoteCheck::Normalized { at },
260            } => write!(
261                f,
262                "in {source} once markdown and line wraps are normalised, at {}",
263                at.join(", ")
264            ),
265            Self::Checked { source, .. } => write!(f, "NOT in {source}"),
266            Self::Operator {
267                source,
268                by: Some(by),
269            } => write!(f, "{source}: checked by the operator, {by}"),
270            Self::Operator { source, by: None } => write!(
271                f,
272                "{source}: NOT CHECKED. The operator checks this source; --yes refuses \
273                 without --quote-checked-by <who>"
274            ),
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    fn one(at: &str, text: &str) -> Vec<(String, &'static str)> {
284        vec![(at.to_string(), Box::leak(text.to_string().into_boxed_str()))]
285    }
286
287    #[test]
288    fn normalising_removes_layout_and_emphasis_only() {
289        assert_eq!(
290            normalize("  a⏎\n   **pending your\tapproval**  of `x`  "),
291            "a⏎ pending your approval of x"
292        );
293        // Case, punctuation and typographic characters are words
294        assert_eq!(normalize("It’s — “so”"), "It’s — “so”");
295        assert_eq!(normalize(" \n* `` **"), "");
296    }
297
298    #[test]
299    fn an_exact_quote_is_found_everywhere_it_is() {
300        let doc = serde_json::json!({
301            "_blind": "abc",
302            "rounds": [{"position": "P100 first. Then P47."}, {"rationale": "**P100 first.**"}],
303            "a/b": "P100 first."
304        });
305        let leaves = string_leaves("", &doc);
306        assert!(leaves.iter().all(|(at, _)| at != "/_blind"));
307        assert_eq!(
308            check("P100 first.", leaves).unwrap(),
309            QuoteCheck::Exact {
310                at: vec![
311                    "/a~1b".into(),
312                    "/rounds/0/position".into(),
313                    "/rounds/1/rationale".into(),
314                ]
315            }
316        );
317    }
318
319    #[test]
320    fn a_wrapped_or_bold_source_matches_only_normalised() {
321        let source = "a\nsentence for the Protocol will be put to you at the next review of\n\
322                      accumulated Protocol revisions.";
323        let quote = "a sentence for the Protocol will be put to you at the next review of \
324                     accumulated Protocol revisions.";
325        assert_eq!(
326            check(quote, one("ops", source)).unwrap(),
327            QuoteCheck::Normalized {
328                at: vec!["ops".into()]
329            }
330        );
331        assert!(
332            check(
333                "pending your approval",
334                one("ops", "**pending your approval** of")
335            )
336            .unwrap()
337            .found()
338        );
339    }
340
341    #[test]
342    fn a_missing_quote_names_the_closest_location() {
343        let sources = vec![
344            ("/a".to_string(), "nothing like it"),
345            (
346                "/b".to_string(),
347                "The slot will be built after the sitting.",
348            ),
349        ];
350        let QuoteCheck::Missing { closest: Some(c) } =
351            check("The slot will be built before the sitting.", sources)
352                .unwrap()
353        else {
354            panic!("not missing")
355        };
356        assert_eq!(c.at, "/b");
357        assert_eq!(c.from, QuoteEnd::Start);
358        assert_eq!(c.matched, "The slot will be built ".chars().count());
359        assert!(c.departs.starts_with("before"));
360
361        let QuoteCheck::Missing { closest: Some(c) } = check(
362            "Totally different start, the sitting.",
363            one("/b", "after the sitting."),
364        )
365        .unwrap() else {
366            panic!("not missing")
367        };
368        assert_eq!(c.from, QuoteEnd::End);
369        assert!(c.departs.ends_with("start,"));
370
371        assert_eq!(
372            check("zzz", one("/a", "abc")).unwrap(),
373            QuoteCheck::Missing { closest: None }
374        );
375    }
376
377    #[test]
378    fn an_empty_quote_is_refused() {
379        assert_eq!(check(" ** ` ", one("/a", "x")), Err(QuoteError::Empty));
380    }
381}