Skip to main content

a3s_code_core/research/
review_batch.rs

1//! Bounded, digest-bound batches of reviewer findings.
2
3use super::{
4    digest, validate_digest_field, validate_id, ResearchContractError, ResearchReviewFindingV1,
5    ResearchReviewStatusV1,
6};
7use serde::{Deserialize, Serialize};
8
9pub const RESEARCH_REVIEW_BATCH_SCHEMA_V1: &str = "a3s.code.review-batch.v1";
10pub const RESEARCH_MAX_REVIEW_FINDINGS: usize = 512;
11const RESEARCH_REVIEW_BATCH_DIGEST_DOMAIN: &str = "a3s.code.review-batch.identity.v1";
12
13/// One immutable projection of an evaluator result into bounded findings.
14///
15/// A batch does not define a rubric or a business decision. It only prevents
16/// a host from publishing a partially mixed reviewer response: every finding
17/// must belong to the same project/run, evaluation record, and evidence
18/// snapshot. A batch may contain zero findings to represent a clean reviewer
19/// result; the evaluator record remains the authoritative result and evidence
20/// identity. Human resolution remains an explicit operation on each finding.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase", deny_unknown_fields)]
23pub struct ResearchReviewBatchV1 {
24    pub schema: String,
25    pub batch_id: String,
26    pub project_id: String,
27    pub run_id: String,
28    pub evaluation_record_digest: String,
29    pub evidence_digest: String,
30    pub findings: Vec<ResearchReviewFindingV1>,
31    pub batch_digest: String,
32}
33
34impl ResearchReviewBatchV1 {
35    /// Construct a batch against the admitted Run and exact evaluator record.
36    ///
37    /// The identity-only [`new`](Self::new) constructor remains available for
38    /// compatibility with callers that only have wire-level digests. New
39    /// reviewer pipelines should use this constructor so the project/run
40    /// namespace and evaluator evidence snapshot are closed at admission. A
41    /// newly admitted batch must contain only open findings; resolved or
42    /// waived findings must be restored from the already-published batch and
43    /// changed through the explicit transition methods below.
44    pub fn new_for_run(
45        batch_id: impl Into<String>,
46        run: &crate::research::ResearchRunV1,
47        record: &crate::evaluation::EvaluationRecordV1,
48        evidence_digest: impl Into<String>,
49        findings: Vec<ResearchReviewFindingV1>,
50    ) -> Result<Self, ResearchContractError> {
51        let batch = Self::new(
52            batch_id,
53            run.project_id.clone(),
54            run.run_id.clone(),
55            record.record_digest.clone(),
56            evidence_digest,
57            findings,
58        )?;
59        batch.validate_for_run(run, record)?;
60        if batch
61            .findings
62            .iter()
63            .any(|finding| !matches!(finding.status, ResearchReviewStatusV1::Open))
64        {
65            return Err(ResearchContractError::InvalidField("finding.status"));
66        }
67        Ok(batch)
68    }
69
70    pub fn new(
71        batch_id: impl Into<String>,
72        project_id: impl Into<String>,
73        run_id: impl Into<String>,
74        evaluation_record_digest: impl Into<String>,
75        evidence_digest: impl Into<String>,
76        mut findings: Vec<ResearchReviewFindingV1>,
77    ) -> Result<Self, ResearchContractError> {
78        findings.sort_unstable_by(|left, right| left.finding_id.cmp(&right.finding_id));
79        let mut batch = Self {
80            schema: RESEARCH_REVIEW_BATCH_SCHEMA_V1.to_owned(),
81            batch_id: batch_id.into(),
82            project_id: project_id.into(),
83            run_id: run_id.into(),
84            evaluation_record_digest: evaluation_record_digest.into(),
85            evidence_digest: evidence_digest.into(),
86            findings,
87            batch_digest: String::new(),
88        };
89        batch.validate_without_digest()?;
90        batch.batch_digest = batch.expected_digest()?;
91        Ok(batch)
92    }
93
94    pub fn validate(&self) -> Result<(), ResearchContractError> {
95        self.validate_without_digest()?;
96        validate_digest_field("batchDigest", &self.batch_digest)?;
97        if self.batch_digest != self.expected_digest()? {
98            return Err(ResearchContractError::DigestMismatch("batchDigest"));
99        }
100        Ok(())
101    }
102
103    /// Decode a bounded JSON batch and validate every nested finding and
104    /// digest before returning it to a caller at a process boundary.
105    pub fn from_slice(bytes: &[u8]) -> Result<Self, ResearchContractError> {
106        let batch: Self = super::decode_json_slice(bytes)?;
107        batch.validate()?;
108        Ok(batch)
109    }
110
111    /// Encode a validated batch for a process boundary.
112    pub fn to_vec(&self) -> Result<Vec<u8>, ResearchContractError> {
113        self.validate()?;
114        super::encode_json(self)
115    }
116
117    /// Validate this batch against the admitted Run and exact evaluator
118    /// record that the host intends to publish.
119    pub fn validate_for_run(
120        &self,
121        run: &crate::research::ResearchRunV1,
122        record: &crate::evaluation::EvaluationRecordV1,
123    ) -> Result<(), ResearchContractError> {
124        self.validate()?;
125        run.validate_reviewable()?;
126        record
127            .validate()
128            .map_err(|_| ResearchContractError::InvalidField("evaluationRecord"))?;
129        if run.project_id != self.project_id {
130            return Err(ResearchContractError::InvalidField("researchRun.projectId"));
131        }
132        if run.run_id != self.run_id {
133            return Err(ResearchContractError::InvalidField("researchRun.runId"));
134        }
135        if record.record_digest != self.evaluation_record_digest {
136            return Err(ResearchContractError::InvalidField(
137                "evaluationRecord.recordDigest",
138            ));
139        }
140        if record.result.target.run_id != run.run_id {
141            return Err(ResearchContractError::InvalidField(
142                "evaluationRecord.target.runId",
143            ));
144        }
145        if record.result.evidence_digest != run.evidence_snapshot_digest {
146            return Err(ResearchContractError::InvalidField(
147                "evaluationRecord.evidenceDigest",
148            ));
149        }
150        if record.result.evidence_digest != self.evidence_digest {
151            return Err(ResearchContractError::InvalidField(
152                "evaluationRecord.evidenceDigest",
153            ));
154        }
155        for finding in &self.findings {
156            if finding.evaluator_id != record.result.evaluator_id {
157                return Err(ResearchContractError::InvalidField("finding.evaluatorId"));
158            }
159        }
160        Ok(())
161    }
162
163    /// Resolve one finding and rebind the batch identity atomically.
164    pub fn resolve_finding(
165        &mut self,
166        finding_id: &str,
167        resolution_digest: impl Into<String>,
168    ) -> Result<(), ResearchContractError> {
169        self.validate()?;
170        let finding = self
171            .findings
172            .iter_mut()
173            .find(|finding| finding.finding_id == finding_id)
174            .ok_or(ResearchContractError::InvalidField("findingId"))?;
175        finding.resolve(resolution_digest)?;
176        self.batch_digest = self.expected_digest()?;
177        self.validate()
178    }
179
180    /// Waive one finding and rebind the batch identity atomically.
181    pub fn waive_finding(
182        &mut self,
183        finding_id: &str,
184        resolution_digest: impl Into<String>,
185    ) -> Result<(), ResearchContractError> {
186        self.validate()?;
187        let finding = self
188            .findings
189            .iter_mut()
190            .find(|finding| finding.finding_id == finding_id)
191            .ok_or(ResearchContractError::InvalidField("findingId"))?;
192        finding.waive(resolution_digest)?;
193        self.batch_digest = self.expected_digest()?;
194        self.validate()
195    }
196
197    fn validate_without_digest(&self) -> Result<(), ResearchContractError> {
198        if self.schema != RESEARCH_REVIEW_BATCH_SCHEMA_V1 {
199            return Err(ResearchContractError::UnsupportedSchema);
200        }
201        validate_id("batchId", &self.batch_id)?;
202        validate_id("projectId", &self.project_id)?;
203        validate_id("runId", &self.run_id)?;
204        validate_digest_field("evaluationRecordDigest", &self.evaluation_record_digest)?;
205        validate_digest_field("evidenceDigest", &self.evidence_digest)?;
206        if self.findings.len() > RESEARCH_MAX_REVIEW_FINDINGS {
207            return Err(ResearchContractError::InvalidField("findings"));
208        }
209        for pair in self.findings.windows(2) {
210            if pair[0].finding_id >= pair[1].finding_id {
211                return Err(ResearchContractError::InvalidField("findings"));
212            }
213        }
214        for finding in &self.findings {
215            finding.validate()?;
216            if finding.project_id != self.project_id || finding.run_id != self.run_id {
217                return Err(ResearchContractError::InvalidField("finding.identity"));
218            }
219            if finding.evaluation_record_digest.as_deref()
220                != Some(self.evaluation_record_digest.as_str())
221            {
222                return Err(ResearchContractError::InvalidField(
223                    "finding.evaluationRecordDigest",
224                ));
225            }
226            if finding
227                .evidence_digests
228                .binary_search(&self.evidence_digest)
229                .is_err()
230            {
231                return Err(ResearchContractError::InvalidField(
232                    "finding.evidenceDigest",
233                ));
234            }
235        }
236        Ok(())
237    }
238
239    fn expected_digest(&self) -> Result<String, ResearchContractError> {
240        #[derive(Serialize)]
241        struct Identity<'a> {
242            schema: &'a str,
243            batch_id: &'a str,
244            project_id: &'a str,
245            run_id: &'a str,
246            evaluation_record_digest: &'a str,
247            evidence_digest: &'a str,
248            findings: &'a [ResearchReviewFindingV1],
249        }
250        digest(
251            RESEARCH_REVIEW_BATCH_DIGEST_DOMAIN,
252            &Identity {
253                schema: &self.schema,
254                batch_id: &self.batch_id,
255                project_id: &self.project_id,
256                run_id: &self.run_id,
257                evaluation_record_digest: &self.evaluation_record_digest,
258                evidence_digest: &self.evidence_digest,
259                findings: &self.findings,
260            },
261        )
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::evaluation::{EvaluationRecordV1, EvaluationResultV1, ExecutionTargetV1};
269    use crate::research::{
270        ResearchReviewCategoryV1, ResearchReviewSeverityV1, ResearchReviewStatusV1,
271    };
272
273    fn digest(ch: char) -> String {
274        format!("sha256:{}", ch.to_string().repeat(64))
275    }
276
277    fn finding(id: &str, record: &EvaluationRecordV1) -> ResearchReviewFindingV1 {
278        ResearchReviewFindingV1::new(
279            id,
280            "project-1",
281            "run-1",
282            digest('a'),
283            ResearchReviewCategoryV1::Citation,
284            ResearchReviewSeverityV1::Warning,
285            "citation needs review",
286            None,
287            vec![record.result.evidence_digest.clone()],
288            record.result.evaluator_id.clone(),
289            3,
290        )
291        .unwrap()
292        .bind_evaluation_record(record)
293        .unwrap()
294    }
295
296    fn record() -> EvaluationRecordV1 {
297        EvaluationRecordV1::new(
298            EvaluationResultV1::new(
299                "reviewer",
300                ExecutionTargetV1::new("session-1", "run-1"),
301                "aux-1",
302                "needs_review",
303                serde_json::json!({"finding_count": 2}),
304                digest('b'),
305            )
306            .unwrap(),
307            2,
308        )
309        .unwrap()
310    }
311
312    #[test]
313    fn batch_sorts_findings_and_keeps_human_decisions_explicit() {
314        let record = record();
315        let mut batch = ResearchReviewBatchV1::new(
316            "batch-1",
317            "project-1",
318            "run-1",
319            record.record_digest.clone(),
320            record.result.evidence_digest.clone(),
321            vec![finding("finding-2", &record), finding("finding-1", &record)],
322        )
323        .unwrap();
324        assert_eq!(batch.findings[0].finding_id, "finding-1");
325        assert!(batch
326            .findings
327            .iter()
328            .all(|finding| finding.status == ResearchReviewStatusV1::Open));
329        let before = batch.batch_digest.clone();
330        batch.resolve_finding("finding-1", digest('c')).unwrap();
331        assert_ne!(before, batch.batch_digest);
332        assert!(batch.validate().is_ok());
333    }
334
335    #[test]
336    fn batch_rejects_mixed_run_or_evidence_and_tampering() {
337        let record = record();
338        let other_run_record = EvaluationRecordV1::new(
339            EvaluationResultV1::new(
340                "reviewer",
341                ExecutionTargetV1::new("session-2", "run-2"),
342                "aux-2",
343                "needs_review",
344                serde_json::json!({"finding_count": 1}),
345                digest('b'),
346            )
347            .unwrap(),
348            2,
349        )
350        .unwrap();
351        let mixed = ResearchReviewFindingV1::new(
352            "finding-1",
353            "project-1",
354            "run-2",
355            digest('a'),
356            ResearchReviewCategoryV1::Citation,
357            ResearchReviewSeverityV1::Warning,
358            "citation needs review",
359            None,
360            vec![other_run_record.result.evidence_digest.clone()],
361            "reviewer",
362            3,
363        )
364        .unwrap()
365        .bind_evaluation_record(&other_run_record)
366        .unwrap();
367        assert_eq!(
368            ResearchReviewBatchV1::new(
369                "batch-1",
370                "project-1",
371                "run-1",
372                record.record_digest.clone(),
373                record.result.evidence_digest.clone(),
374                vec![mixed],
375            ),
376            Err(ResearchContractError::InvalidField("finding.identity"))
377        );
378
379        let mut other = finding("finding-1", &record);
380        other.run_id = "run-2".to_owned();
381        assert!(matches!(
382            ResearchReviewBatchV1::new(
383                "batch-1",
384                "project-1",
385                "run-1",
386                record.record_digest.clone(),
387                record.result.evidence_digest.clone(),
388                vec![other],
389            ),
390            Err(ResearchContractError::DigestMismatch("findingDigest"))
391        ));
392
393        let mut batch = ResearchReviewBatchV1::new(
394            "batch-1",
395            "project-1",
396            "run-1",
397            record.record_digest.clone(),
398            record.result.evidence_digest.clone(),
399            vec![finding("finding-1", &record)],
400        )
401        .unwrap();
402        batch.findings[0].message = "tampered".to_owned();
403        assert_eq!(
404            batch.validate(),
405            Err(ResearchContractError::DigestMismatch("findingDigest"))
406        );
407    }
408
409    #[test]
410    fn empty_batch_represents_a_clean_review_result() {
411        let record = record();
412        let batch = ResearchReviewBatchV1::new(
413            "clean-batch",
414            "project-1",
415            "run-1",
416            record.record_digest.clone(),
417            record.result.evidence_digest.clone(),
418            Vec::new(),
419        )
420        .unwrap();
421
422        assert!(batch.findings.is_empty());
423        assert!(batch.validate().is_ok());
424    }
425
426    #[test]
427    fn batch_round_trip_is_strict_and_tamper_evident() {
428        let record = record();
429        let batch = ResearchReviewBatchV1::new(
430            "wire-batch",
431            "project-1",
432            "run-1",
433            record.record_digest.clone(),
434            record.result.evidence_digest.clone(),
435            vec![finding("finding-1", &record)],
436        )
437        .unwrap();
438
439        let encoded = batch.to_vec().unwrap();
440        let reopened = ResearchReviewBatchV1::from_slice(&encoded).unwrap();
441        assert_eq!(reopened, batch);
442        assert!(reopened.validate().is_ok());
443
444        let mut with_unknown_field: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
445        with_unknown_field["unexpected"] = serde_json::Value::Bool(true);
446        let with_unknown_field = serde_json::to_vec(&with_unknown_field).unwrap();
447        assert!(ResearchReviewBatchV1::from_slice(&with_unknown_field).is_err());
448
449        let mut tampered = reopened;
450        tampered.findings[0].message = "changed after publication".to_owned();
451        assert_eq!(
452            tampered.validate(),
453            Err(ResearchContractError::DigestMismatch("findingDigest"))
454        );
455    }
456
457    #[test]
458    fn closed_batch_round_trip_preserves_terminal_finding_state() {
459        let record = record();
460        let mut resolved = ResearchReviewBatchV1::new(
461            "resolved-wire-batch",
462            "project-1",
463            "run-1",
464            record.record_digest.clone(),
465            record.result.evidence_digest.clone(),
466            vec![finding("finding-1", &record)],
467        )
468        .unwrap();
469        resolved.resolve_finding("finding-1", digest('c')).unwrap();
470        let reopened: ResearchReviewBatchV1 =
471            serde_json::from_slice(&serde_json::to_vec(&resolved).unwrap()).unwrap();
472        assert_eq!(reopened, resolved);
473        assert_eq!(
474            reopened.findings[0].status,
475            ResearchReviewStatusV1::Resolved
476        );
477        assert!(reopened.validate().is_ok());
478        assert_eq!(
479            reopened.clone().resolve_finding("finding-1", digest('d')),
480            Err(ResearchContractError::InvalidTransition {
481                from: "resolved",
482                to: "resolved"
483            })
484        );
485        assert_eq!(
486            reopened.clone().waive_finding("finding-1", digest('e')),
487            Err(ResearchContractError::InvalidTransition {
488                from: "resolved",
489                to: "waived"
490            })
491        );
492
493        let mut waived = ResearchReviewBatchV1::new(
494            "waived-wire-batch",
495            "project-1",
496            "run-1",
497            record.record_digest.clone(),
498            record.result.evidence_digest.clone(),
499            vec![finding("finding-2", &record)],
500        )
501        .unwrap();
502        waived.waive_finding("finding-2", digest('f')).unwrap();
503        let mut reopened: ResearchReviewBatchV1 =
504            serde_json::from_slice(&serde_json::to_vec(&waived).unwrap()).unwrap();
505        assert_eq!(reopened, waived);
506        assert_eq!(reopened.findings[0].status, ResearchReviewStatusV1::Waived);
507        assert!(reopened.validate().is_ok());
508        assert_eq!(
509            reopened.resolve_finding("finding-2", digest('1')),
510            Err(ResearchContractError::InvalidTransition {
511                from: "waived",
512                to: "resolved"
513            })
514        );
515    }
516}