Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-review-classifier/src/aggregate.rs
atlas 88a55f136e swarm-review-classifier: answer a try-out with one claude run per classification
A new service crate. It reads its queue client secret from the store at
start, takes try-outs on the review classifier subject in its queue
group, and for each run reads the Anthropic API key from the store,
builds the shared context (commits with full messages, diff with
lockfiles collapsed), runs one claude --print per classification with no
session, no tools, no MCP server and the key in the child's environment
only, validates the JSON answer and aggregates the rows. A missing key or
a run where no classification got an answer fails the run; one failed
classification is an excluded row with its error. One journal line per
run names the operator, repo, PR, head SHA, rows and overall verdict.

swarm-secret-client gains the two paths the service reads.
2026-10-12 13:11:21 +02:00

159 lines
5.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Turning answers into the result table and the overall verdict, as the
//! architecture doc's "Aggregation into the verdict" states it.
use swarm_queue_client::review_classifier::{Classification, Overall, Polarity, Row, Verdict};
use crate::answer::Answer;
/// The row for `c`, from its answer or the reason it has none.
///
/// A row counts when it is applicable and its certainty reaches the
/// threshold. A counted row passes when its verdict is on the wanted side of
/// its polarity. A row without an answer is never counted.
#[must_use]
pub fn row(c: &Classification, outcome: Result<Answer, String>) -> Row {
match outcome {
Ok(a) => {
let counted = a.verdict != Verdict::NotApplicable && a.certainty >= c.threshold;
let wanted = match c.polarity {
Polarity::Positive => Verdict::Match,
Polarity::Negative => Verdict::NoMatch,
};
Row {
name: c.name.clone(),
polarity: c.polarity,
verdict: a.verdict,
certainty: a.certainty,
threshold: c.threshold,
counted,
passed: counted && a.verdict == wanted,
reason: a.reason,
error: None,
}
}
Err(error) => Row {
name: c.name.clone(),
polarity: c.polarity,
verdict: Verdict::NotApplicable,
certainty: 0,
threshold: c.threshold,
counted: false,
passed: false,
reason: String::new(),
error: Some(error),
},
}
}
/// Request changes when any counted row failed, approve otherwise, also when
/// nothing counted.
#[must_use]
pub fn overall(rows: &[Row]) -> Overall {
if rows.iter().any(|r| r.counted && !r.passed) {
Overall::RequestChanges
} else {
Overall::Approve
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classification(polarity: Polarity, threshold: u8) -> Classification {
Classification {
name: "c".to_owned(),
description: "d".to_owned(),
polarity,
threshold,
}
}
fn answer(verdict: Verdict, certainty: u8) -> Answer {
Answer {
verdict,
certainty,
reason: "r".to_owned(),
}
}
/// Every polarity × verdict at and below the threshold: what counts,
/// what passes, and the overall verdict the row alone gives.
#[test]
fn polarity_verdict_and_threshold_decide_counted_and_passed() {
use Overall::{Approve, RequestChanges};
use Polarity::{Negative, Positive};
use Verdict::{Match, NoMatch, NotApplicable};
let cases = [
// polarity, verdict, certainty, counted, passed, overall
(Positive, Match, 80, true, true, Approve),
(Positive, NoMatch, 80, true, false, RequestChanges),
(Negative, Match, 80, true, false, RequestChanges),
(Negative, NoMatch, 80, true, true, Approve),
(Positive, NoMatch, 79, false, false, Approve),
(Negative, Match, 79, false, false, Approve),
(Positive, NotApplicable, 100, false, false, Approve),
(Negative, NotApplicable, 100, false, false, Approve),
];
for (polarity, verdict, certainty, counted, passed, want) in cases {
let r = row(
&classification(polarity, 80),
Ok(answer(verdict, certainty)),
);
assert_eq!(
(r.counted, r.passed, overall(std::slice::from_ref(&r))),
(counted, passed, want),
"{polarity:?} {verdict:?} at {certainty}"
);
}
}
#[test]
fn nothing_counted_is_approve() {
assert_eq!(overall(&[]), Overall::Approve);
let excluded = [
row(
&classification(Polarity::Positive, 90),
Ok(answer(Verdict::NoMatch, 10)),
),
row(
&classification(Polarity::Negative, 0),
Ok(answer(Verdict::NotApplicable, 100)),
),
];
assert_eq!(overall(&excluded), Overall::Approve);
}
#[test]
fn one_failing_row_among_passing_ones_requests_changes() {
let rows = [
row(
&classification(Polarity::Positive, 50),
Ok(answer(Verdict::Match, 90)),
),
row(
&classification(Polarity::Negative, 50),
Ok(answer(Verdict::Match, 90)),
),
];
assert_eq!(overall(&rows), Overall::RequestChanges);
}
#[test]
fn an_error_row_is_excluded_and_never_passes() {
let r = row(
&classification(Polarity::Positive, 0),
Err("timed out".to_owned()),
);
assert!(!r.counted && !r.passed);
assert_eq!(r.error.as_deref(), Some("timed out"));
// A threshold of 0 would count any applicable answer; an error is
// not one.
let failing = row(
&classification(Polarity::Positive, 0),
Ok(answer(Verdict::NoMatch, 0)),
);
assert_eq!(overall(&[r, failing]), Overall::RequestChanges);
}
}