rto_graph/authorship.rs
1//! Who wrote the change under review, read from its commit trailers — so a
2//! reviewer can say when it is reviewing its own output (issue #649, part 3).
3//!
4//! A model reviewing code it wrote is the weakest possible reviewer: it shares
5//! the blind spot that produced the defect. The authorship is already recorded,
6//! because commits made by an agent harness carry a trailer:
7//!
8//! ```text
9//! Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10//! ```
11//!
12//! This module is the pure half — parse the trailers, decide whether one names
13//! the model that is about to review. Reading a commit range and printing a
14//! warning belong to the binary, where git and the engine already are.
15//!
16//! # Warn, never refuse
17//!
18//! Settled by the owner on 27 Aug 2026, and the reason matters for what is here:
19//! a same-model review is weakened, not worthless, and refusing would trade it
20//! for **no** review on exactly the machine most likely to have one model
21//! installed. So nothing in this module returns an error or a veto — the widest
22//! answer it gives is "these two names denote the same model", and the caller's
23//! only move is to say so.
24//!
25//! A commit with no `Co-Authored-By` — a human wrote it — yields no trailer, so
26//! no match, so no warning, and the review proceeds exactly as it did before.
27//! Human-authored changes are never harder to review than machine-authored ones,
28//! which was the thing to avoid.
29//!
30//! # Does the trailer name the model, or the harness that ran it?
31//!
32//! **The trailer is read as naming a model, and the comparison is model-to-model
33//! only.** That is a decision about what can be *compared*, not a claim about
34//! what harnesses write.
35//!
36//! The registry side is unambiguous: `ModelTask::Review` — named rather than
37//! linked, because the registry is behind `models` and this module is not —
38//! resolves to a model, never to a harness. So a model-to-harness comparison is
39//! a category error whichever way the trailer happens to be written, and there
40//! is no rule that could rescue it. The trailer side, by contrast, is free text
41//! with no schema — a harness may write its product name (`Claude Code`,
42//! `Cursor`, `Aider`) instead of the weights it ran, and nothing in the string
43//! says which it did. Any rule that *decided* "this one is a harness" would be
44//! guessing at the one thing the format does not record.
45//!
46//! So [`names_same_model`] attempts an identity match against the model name and
47//! gives up silently when it fails. A trailer naming a harness normalises onto no
48//! registry model, does not match, and produces nothing.
49//!
50//! ## Why a mismatch is the benign direction
51//!
52//! The two ways to be wrong are not symmetric.
53//!
54//! A **false negative** — a same-model review that goes unwarned — costs exactly
55//! what today costs, because today there is no warning at all. It is a feature
56//! that did not fire, not a regression.
57//!
58//! A **false positive** — warning that the reviewer wrote the change when it
59//! demonstrably did not — is worse than it looks, because the alternative rule
60//! that would "never miss" is *warn whenever any AI trailer is present*. On this
61//! repository, whose commits are Claude-authored and whose local reviewer is a
62//! Qwen GGUF, that rule fires on every single run while being false every single
63//! time. A warning that is always on is a warning nobody reads, and it would take
64//! the true ones down with it.
65//!
66//! Hence exact identity or silence. [`identity_tokens`] absorbs the spelling
67//! differences that are certainly not identity differences — case, the separators
68//! between a family and its size, and a bracketed qualifier like `(1M context)`,
69//! which describes how a model was *configured* rather than which model it is —
70//! and nothing else.
71
72/// One `Co-Authored-By` trailer.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CoAuthor {
75 /// The display name, exactly as the trailer wrote it — `Claude Opus 5 (1M
76 /// context)`. Kept verbatim so a warning can quote what it actually read
77 /// rather than a normalised form the reader would not find in `git log`.
78 pub name: String,
79 /// The address inside the angle brackets, or the empty string when the
80 /// trailer carried none.
81 pub email: String,
82}
83
84/// Every `Co-Authored-By` trailer in one commit message, in the order it appears.
85///
86/// Deliberately not a full RFC-822-ish trailer parser: this reads the one key it
87/// needs, case-insensitively, from any line of the message. Git's own trailer
88/// rules require the block to be at the end and unbroken, and a message that
89/// slightly violates them still records who wrote the code — which is the fact
90/// wanted here, not a syntactic verdict about the commit.
91///
92/// Duplicates are kept. Two identical trailers are a fact about the message, and
93/// the caller de-duplicates across a range where that is what it wants.
94#[must_use]
95pub fn co_authors(message: &str) -> Vec<CoAuthor> {
96 const KEY: &str = "co-authored-by:";
97 let mut out = Vec::new();
98 for line in message.lines() {
99 let line = line.trim();
100 let Some(head) = line.get(..KEY.len()) else {
101 continue;
102 };
103 if !head.eq_ignore_ascii_case(KEY) {
104 continue;
105 }
106 let value = line[KEY.len()..].trim();
107 // The address is the bracketed tail, if there is one. A trailer with no
108 // `<...>` is still an authorship claim and is kept with an empty email
109 // rather than dropped — the name is the half this module compares.
110 let (name, email) = match value.rfind('<') {
111 Some(at) => {
112 let email = value[at + 1..].trim_end().trim_end_matches('>');
113 (value[..at].trim(), email.trim())
114 }
115 None => (value, ""),
116 };
117 if name.is_empty() && email.is_empty() {
118 continue;
119 }
120 out.push(CoAuthor {
121 name: name.to_owned(),
122 email: email.to_owned(),
123 });
124 }
125 out
126}
127
128/// Normalise a model or trailer name to the tokens that identify it.
129///
130/// Lowercased and split on **every non-alphanumeric character**, so
131/// `claude-opus-5`, `claude_opus_5`, `claude.opus.5` and `Claude Opus 5` all
132/// reach the same answer; and with any bracketed span dropped — `(…)` **and**
133/// `[…]` alike, since a qualifier is written both ways. `(1M context)` says how a
134/// model was *configured*, not which model it is, and two runs of one model at
135/// two context sizes are the same weights with the same blind spot.
136///
137/// Nothing else is removed, and nothing is added. This absorbs spelling, never
138/// meaning — see the module docs for why the rule stops here.
139#[must_use]
140pub fn identity_tokens(name: &str) -> Vec<String> {
141 let mut out = Vec::new();
142 let mut current = String::new();
143 let mut depth = 0usize;
144 for ch in name.chars() {
145 match ch {
146 '(' | '[' => {
147 depth += 1;
148 continue;
149 }
150 ')' | ']' => {
151 depth = depth.saturating_sub(1);
152 continue;
153 }
154 _ => {}
155 }
156 if depth > 0 {
157 continue;
158 }
159 if ch.is_alphanumeric() {
160 current.extend(ch.to_lowercase());
161 } else if !current.is_empty() {
162 out.push(std::mem::take(&mut current));
163 }
164 }
165 if !current.is_empty() {
166 out.push(current);
167 }
168 out
169}
170
171/// Whether a trailer's `name` denotes the same model as `model`.
172///
173/// Exact equality of the [`identity_tokens`] sequence, and nothing looser. A
174/// trailer that names a harness, a person, or a different model returns `false`,
175/// which the caller renders as silence — see the module docs for why that is the
176/// direction to fail in.
177#[must_use]
178pub fn names_same_model(name: &str, model: &str) -> bool {
179 let left = identity_tokens(name);
180 // An empty token list matches nothing, including another empty one: two names
181 // that normalise to nothing are not evidence that they are the same model.
182 !left.is_empty() && left == identity_tokens(model)
183}
184
185/// How much of a commit range the reviewing model wrote — see
186/// [`reviewers_own_work`].
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct OwnWork {
189 /// How many of the messages carry at least one matching trailer.
190 ///
191 /// Counted separately from [`OwnWork::names`] because the two answer
192 /// different questions and are easy to conflate into a sentence that is
193 /// quietly false: one model spelled two ways across nine commits is two names
194 /// and nine commits, and a warning that says "2 commits" would be wrong about
195 /// the only number a reader would act on.
196 pub commits: usize,
197 /// The matching trailer names as written, de-duplicated, first-seen order —
198 /// so a warning quotes what `git log` would show rather than a normalised
199 /// form the reader could not search for.
200 pub names: Vec<String>,
201}
202
203impl OwnWork {
204 /// Whether the reviewing model wrote any of the range.
205 #[must_use]
206 pub fn is_empty(&self) -> bool {
207 self.commits == 0
208 }
209}
210
211/// Which commits in a range `model` co-authored, by its trailers.
212///
213/// A default [`OwnWork`] means no commit in the range was written by this model,
214/// which is the ordinary case and is never an error: see the module docs for why
215/// silence is the direction this fails in.
216#[must_use]
217pub fn reviewers_own_work(messages: &[String], model: &str) -> OwnWork {
218 let mut out = OwnWork::default();
219 for message in messages {
220 let mut matched = false;
221 for author in co_authors(message) {
222 if !names_same_model(&author.name, model) {
223 continue;
224 }
225 matched = true;
226 if !out.names.contains(&author.name) {
227 out.names.push(author.name);
228 }
229 }
230 out.commits += usize::from(matched);
231 }
232 out
233}
234
235#[cfg(test)]
236mod tests {
237 use super::{co_authors, identity_tokens, names_same_model, reviewers_own_work};
238
239 #[test]
240 fn a_trailer_is_read_from_a_real_commit_message() {
241 let message = "feat(review): do the thing\n\
242 \n\
243 A body paragraph.\n\
244 \n\
245 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>\n\
246 Claude-Session: https://example.invalid/s\n";
247 let authors = co_authors(message);
248 assert_eq!(authors.len(), 1);
249 assert_eq!(authors[0].name, "Claude Opus 5 (1M context)");
250 assert_eq!(authors[0].email, "noreply@anthropic.com");
251 }
252
253 #[test]
254 fn the_key_is_matched_case_insensitively_and_a_missing_address_is_kept() {
255 let authors = co_authors("x\n\nco-authored-by: qwen3-8b\nCO-AUTHORED-BY: A B <a@b>\n");
256 assert_eq!(authors.len(), 2);
257 assert_eq!(authors[0].name, "qwen3-8b");
258 assert_eq!(
259 authors[0].email, "",
260 "a trailer with no address is still an authorship claim"
261 );
262 assert_eq!(authors[1].email, "a@b");
263 }
264
265 #[test]
266 fn a_human_commit_carries_no_trailer_and_therefore_no_match() {
267 // The case that must never become a reason to refuse: a human-authored
268 // commit yields nothing, so nothing is warned about and the review
269 // proceeds exactly as it did before.
270 let message = "fix: correct the off-by-one\n\nNoticed while reading.\n";
271 assert!(co_authors(message).is_empty());
272 assert!(
273 reviewers_own_work(&[message.to_owned()], "qwen3-8b").is_empty(),
274 "a human-authored commit must never be harder to review"
275 );
276 }
277
278 #[test]
279 fn spelling_differences_that_are_not_identity_differences_are_absorbed() {
280 assert_eq!(
281 identity_tokens("Claude Opus 5 (1M context)"),
282 ["claude", "opus", "5"]
283 );
284 assert_eq!(identity_tokens("claude-opus-5"), ["claude", "opus", "5"]);
285 assert_eq!(identity_tokens("qwen3.8-27b"), ["qwen3", "8", "27b"]);
286 assert!(names_same_model(
287 "Claude Opus 5 (1M context)",
288 "claude-opus-5"
289 ));
290 assert!(
291 names_same_model("QWEN3_8B", "qwen3-8b"),
292 "case and separator are spelling, not identity"
293 );
294 }
295
296 /// The context qualifier is dropped **because it is a configuration, not an
297 /// identity**: the same weights at two window sizes carry the same blind spot,
298 /// which is the whole reason the warning exists.
299 ///
300 /// Both bracket forms, and every separator — the doc comment claims all of
301 /// this, and a claim about normalisation that nothing checks is the
302 /// contract-drift class this reviewer exists to find. Caught as a suppressed
303 /// finding in review of #649, where the docs said "parenthesised" and
304 /// "`-`, `_`, `.` and `/`" while the code took `[…]` and every
305 /// non-alphanumeric character.
306 #[test]
307 fn a_bracketed_qualifier_does_not_make_it_a_different_model() {
308 assert!(names_same_model(
309 "Claude Opus 5 (1M context)",
310 "Claude Opus 5"
311 ));
312 assert!(names_same_model(
313 "Claude Opus 5 (200K context)",
314 "claude opus 5"
315 ));
316 assert!(
317 names_same_model("Claude Opus 5 [1M context]", "claude-opus-5"),
318 "square brackets are dropped too, exactly as the doc comment says"
319 );
320 assert_eq!(
321 identity_tokens("Claude Opus 5 [1M context]"),
322 ["claude", "opus", "5"]
323 );
324 // Every non-alphanumeric character separates, not only the four the doc
325 // comment used to list.
326 for spelling in [
327 "claude-opus-5",
328 "claude_opus_5",
329 "claude.opus.5",
330 "claude/opus/5",
331 "claude:opus:5",
332 "claude+opus+5",
333 ] {
334 assert_eq!(
335 identity_tokens(spelling),
336 ["claude", "opus", "5"],
337 "{spelling} must normalise like every other spelling"
338 );
339 }
340 }
341
342 /// A harness name normalises onto no model name, so it does not match, so
343 /// nothing is printed. This is the mismatch case being benign by
344 /// construction rather than by intention.
345 #[test]
346 fn a_harness_name_matches_no_model_and_is_therefore_silent() {
347 for harness in ["Claude Code", "Cursor", "Aider", "GitHub Copilot"] {
348 assert!(
349 !names_same_model(harness, "claude-opus-5"),
350 "{harness} names a harness, and a harness is not a model"
351 );
352 assert!(!names_same_model(harness, "qwen3-8b"));
353 }
354 }
355
356 /// The realistic negative on this repository, asserted so the feature's
357 /// *silence* here is a measured fact rather than an assumption: commits are
358 /// Claude-authored and the local reviewer is a Qwen GGUF, so nothing fires.
359 #[test]
360 fn a_different_model_is_never_reported_as_the_same_one() {
361 let message = "x\n\nCo-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>\n";
362 assert!(reviewers_own_work(&[message.to_owned()], "qwen3-8b").is_empty());
363 assert!(reviewers_own_work(&[message.to_owned()], "qwen3.8-27b").is_empty());
364 }
365
366 /// The two counts are separate because a sentence that conflates them is
367 /// quietly false: one model spelled two ways over three commits is **two**
368 /// names and **three** commits, and the commit count is the number a reader
369 /// would act on.
370 #[test]
371 fn distinct_names_and_matching_commits_are_counted_separately() {
372 let one = "a\n\nCo-Authored-By: Qwen3 8B <x@y>\n".to_owned();
373 let two = "b\n\nCo-Authored-By: Qwen3 8B <x@y>\n".to_owned();
374 let three = "c\n\nCo-Authored-By: qwen3-8b <x@y>\n".to_owned();
375 let human = "d\n\nnobody else\n".to_owned();
376 let hit = reviewers_own_work(&[one, two, three, human], "qwen3-8b");
377 assert_eq!(
378 hit.names,
379 vec!["Qwen3 8B".to_owned(), "qwen3-8b".to_owned()],
380 "quoted as written, de-duplicated, first-seen order"
381 );
382 assert_eq!(
383 hit.commits, 3,
384 "three commits matched; the fourth is human-authored"
385 );
386 }
387
388 /// One commit naming the model twice is still one commit.
389 #[test]
390 fn a_commit_is_counted_once_however_many_trailers_it_carries() {
391 let message = "a\n\nCo-Authored-By: Qwen3 8B <x@y>\nCo-Authored-By: qwen3-8b <x@y>\n";
392 let hit = reviewers_own_work(&[message.to_owned()], "qwen3-8b");
393 assert_eq!(hit.commits, 1);
394 assert_eq!(hit.names.len(), 2);
395 }
396
397 #[test]
398 fn a_name_that_normalises_to_nothing_matches_nothing() {
399 assert!(
400 !names_same_model("", ""),
401 "two names that say nothing are not evidence of the same model"
402 );
403 assert!(!names_same_model("(1M context)", ""));
404 }
405}