Skip to main content

rustev_core/
generation_eval.rs

1//! Pure post-decision generation (spec 037). The frozen judgment is never an
2//! input to output validation, repair, or backstop selection.
3use std::collections::{BTreeMap, BTreeSet};
4
5use rustev_contract::canonical::canonical_bytes;
6use rustev_contract::definition::{ItemSource, RequiredKind, StepBody};
7use rustev_contract::descriptor::{OutputKind, TextStructure};
8use rustev_contract::evidence::EvidenceRecord;
9use rustev_contract::generation::*;
10use rustev_contract::generation_result::*;
11use rustev_contract::ids::ContentDigest;
12use rustev_contract::judgment::{Derivation, Judgment, Lineage, OutValue, Outcome, Unresolved};
13use rustev_contract::output::{RawOutput, TextValue};
14use rustev_contract::plan::{Normalization, PlanExecution, PlanStepDetail};
15use rustev_contract::request::RequestDoc;
16use rustev_contract::schema;
17use rustev_contract::scope::Scope;
18
19use crate::Compiled;
20use crate::evaluate::{NotReady, RequestIdentityError, Supplied, SupplyError};
21use crate::generation::Checked;
22use crate::template as t;
23use crate::value::Value;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum GenerationError {
27    NotReady(NotReady),
28    /// A compiled invariant or exact rendering failed. Never contains output text.
29    InvalidState(String),
30}
31impl std::fmt::Display for GenerationError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        write!(f, "generation: {self:?}")
34    }
35}
36impl std::error::Error for GenerationError {}
37fn bad(s: impl ToString) -> GenerationError {
38    GenerationError::InvalidState(s.to_string())
39}
40
41/// A backend-neutral generation or repair request. Dispatch belongs to runtime.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct GenerationRequest {
44    pub backend_id: String,
45    pub owner: String,
46    pub screen_projection: Option<Vec<u8>>,
47    pub document: GenerateRequest,
48}
49struct Item {
50    step: String,
51    declaration: Generate,
52    result: TextItem,
53    backstops: BTreeMap<String, TextField>,
54    pending: Option<GenerationRequest>,
55    round: u64,
56    screens: Vec<GenerationRequest>,
57    screening: BTreeMap<String, rustev_contract::screening::ScreeningEvidence>,
58}
59
60/// A decision is frozen before this stage exists. No generation value is stored
61/// in an Evaluation's exact or semantic value maps.
62pub struct GenerationEvaluation<'c> {
63    compiled: &'c Compiled,
64    judgment: Judgment,
65    evidence: EvidenceRecord,
66    steps: Vec<TextStep>,
67    items: Vec<Item>,
68    notices: Vec<TextNotice>,
69    remaining: u64,
70}
71impl<'c> GenerationEvaluation<'c> {
72    pub(crate) fn start(
73        compiled: &'c Compiled,
74        judgment: Judgment,
75        evidence: EvidenceRecord,
76        values: (BTreeMap<String, Value>, BTreeMap<String, Value>),
77        context: (
78            BTreeMap<String, Lineage>,
79            crate::evaluate::GenerationScreening,
80        ),
81        issued: u64,
82    ) -> Result<Self, GenerationError> {
83        let (inputs, exact) = values;
84        let (lineage, screening) = context;
85        let mut stage = Self {
86            compiled,
87            judgment,
88            evidence,
89            steps: vec![],
90            items: vec![],
91            notices: vec![],
92            remaining: compiled
93                .plan
94                .definition
95                .limits
96                .max_semantic_requests
97                .saturating_sub(issued),
98        };
99        let mut roots = BTreeMap::from([
100            ("input".into(), Value::Record(inputs)),
101            ("step".into(), Value::Record(exact)),
102        ]);
103        if let Outcome::Propose { params, .. } = &stage.judgment.outcome {
104            roots.insert(
105                "proposal".into(),
106                Value::Record(
107                    params
108                        .iter()
109                        .map(|(n, v)| Ok((n.clone(), from_out(v)?)))
110                        .collect::<Result<_, GenerationError>>()?,
111                ),
112            );
113        }
114        for step in &compiled.plan.definition.steps {
115            let StepBody::Generate(g) = &step.body else {
116                continue;
117            };
118            let checked = compiled
119                .info
120                .generation
121                .get(&step.id)
122                .ok_or_else(|| bad("missing compiled templates"))?;
123            let matches = match &stage.judgment.outcome {
124                Outcome::Propose { action, .. } => match &g.when {
125                    GenerateWhen::AnyPropose => true,
126                    GenerateWhen::Actions(actions) => actions.contains(action),
127                },
128                _ => false,
129            };
130            let mut result = TextStep {
131                step: step.id.clone(),
132                state: GenerationState::Ran,
133                lineage: stage.judgment.lineage.clone(),
134                items: vec![],
135                text_origin: TextOrigin::None,
136                counts: TextCounts::default(),
137            };
138            if !matches {
139                result.state = GenerationState::Skipped {
140                    reason: GenerationSkipReason::OutcomeNotMatched,
141                };
142            } else {
143                match make_items(&step.id, g, checked, &roots, &lineage) {
144                    Ok(mut items) => {
145                        for item in &mut items {
146                            if item.pending.is_some()
147                                && let Some(reason) = screening.reason(
148                                    g,
149                                    &item.result.id,
150                                    &stage.judgment,
151                                    &compiled.plan.definition,
152                                )
153                            {
154                                complete(item, reason);
155                            }
156                            if item.pending.is_some() {
157                                if stage.remaining == 0 {
158                                    complete(item, TextReason::BudgetExhausted);
159                                } else {
160                                    stage.remaining -= 1;
161                                }
162                            }
163                        }
164                        stage.items.extend(items);
165                    }
166                    Err(u) => {
167                        result.state = GenerationState::Skipped {
168                            reason: GenerationSkipReason::SourceUnresolved(u),
169                        }
170                    }
171                }
172            }
173            stage.steps.push(result);
174        }
175        Ok(stage)
176    }
177
178    /// Available at stage start, before any generation supply.
179    pub fn judgment(&self) -> &Judgment {
180        &self.judgment
181    }
182
183    /// Declaration order, then selected item order. Repairs retain item order.
184    pub fn pending(&self) -> Vec<GenerationRequest> {
185        self.items
186            .iter()
187            .flat_map(|i| {
188                if i.screens.is_empty() {
189                    i.pending.clone().into_iter().collect()
190                } else {
191                    i.screens.clone()
192                }
193            })
194            .collect()
195    }
196
197    pub fn request_document(
198        &self,
199        step: &str,
200        instance: &[String],
201        target: u32,
202        scope: &Scope,
203    ) -> Result<RequestDoc, RequestIdentityError> {
204        if let Some((_, r)) = self.find_screen(step, instance) {
205            let (_, b) =
206                output_screen_binding(&self.compiled.plan, step).expect("bound output screener");
207            if target != 0 {
208                return Err(RequestIdentityError::UnknownTarget {
209                    step: step.into(),
210                    target,
211                });
212            }
213            return Ok(RequestDoc {
214                schema: schema::REQUEST.into(),
215                scope: scope.clone(),
216                backend_id: b.backend_id.clone(),
217                artifact: b.artifact.clone(),
218                descriptor: b.descriptor.clone(),
219                projection: String::from_utf8(
220                    r.screen_projection.clone().expect("screen projection"),
221                )
222                .map_err(|_| RequestIdentityError::ProjectionNotUtf8 { step: step.into() })?,
223                output: OutputKind::Screen,
224                requires: RequiredKind::ScreenVerdict,
225                normalization: Normalization::None,
226            });
227        }
228        let missing = || RequestIdentityError::NotPending {
229            step: step.into(),
230            instance: instance.to_vec(),
231        };
232        let item = self.find(step, instance).ok_or_else(missing)?;
233        let request = item.pending.as_ref().ok_or_else(missing)?;
234        let binding = self
235            .compiled
236            .plan
237            .steps
238            .iter()
239            .find_map(|s| match &s.detail {
240                PlanStepDetail::Generate(b) if s.id == step => Some(b),
241                _ => None,
242            })
243            .ok_or_else(missing)?;
244        let unknown = || RequestIdentityError::UnknownTarget {
245            step: step.into(),
246            target,
247        };
248        let (backend_id, artifact, descriptor) = if target == 0 {
249            (&binding.backend_id, &binding.artifact, &binding.descriptor)
250        } else {
251            let PlanExecution::Declared(e) = &self.compiled.plan.execution else {
252                return Err(unknown());
253            };
254            let f = e
255                .fallbacks
256                .iter()
257                .find(|f| f.step == step && f.target == target)
258                .ok_or_else(unknown)?;
259            (&f.backend_id, &f.artifact, &f.descriptor)
260        };
261        // This is the new generate operation in the existing request envelope.
262        // Plan identity binds profile, template and resolved vocabulary as well.
263        let envelope = serde_json::json!({"operation":"generate", "plan_id":self.compiled.id, "request":request.document});
264        let projection = canonical_bytes(&envelope)
265            .ok()
266            .and_then(|b| String::from_utf8(b).ok())
267            .ok_or_else(|| RequestIdentityError::ProjectionNotUtf8 { step: step.into() })?;
268        let max_output_bytes = request
269            .document
270            .output
271            .fields
272            .iter()
273            .map(|f| match &f.ty {
274                GeneratedType::Text { max_bytes, .. } => *max_bytes,
275                GeneratedType::List {
276                    vocabulary,
277                    max_items,
278                } => {
279                    // Bounded by the contract (64 items, bounded terms), but
280                    // the total must never wrap whatever a typed caller built.
281                    max_items.saturating_mul(
282                        binding
283                            .vocabularies
284                            .get(vocabulary)
285                            .into_iter()
286                            .flatten()
287                            .map(|s| s.len() as u64)
288                            .max()
289                            .unwrap_or(0),
290                    )
291                }
292            })
293            .fold(0u64, u64::saturating_add);
294        Ok(RequestDoc {
295            schema: schema::REQUEST.into(),
296            scope: scope.clone(),
297            backend_id: backend_id.clone(),
298            artifact: artifact.clone(),
299            descriptor: descriptor.clone(),
300            projection,
301            output: OutputKind::TextRecord {
302                structure: TextStructure::JsonSchema,
303                max_output_bytes,
304                languages: vec![request.document.language.clone()],
305                streaming: false,
306            },
307            requires: RequiredKind::TextRecord,
308            normalization: Normalization::None,
309        })
310    }
311
312    fn find_screen(&self, step: &str, instance: &[String]) -> Option<(&Item, &GenerationRequest)> {
313        self.items.iter().find_map(|i| {
314            i.screens
315                .iter()
316                .find(|r| r.document.step == step && r.document.instance == instance)
317                .map(|r| (i, r))
318        })
319    }
320
321    fn screen_check(&self, step: &str, raw: &RawOutput) -> Result<(), Unresolved> {
322        let invalid = || Unresolved::InvalidBackendOutput {
323            detail: "invalid output screening verdict".into(),
324        };
325        let (_, b) = output_screen_binding(&self.compiled.plan, step).ok_or_else(invalid)?;
326        let declared = self
327            .compiled
328            .info
329            .screen_categories
330            .get(&b.backend_id)
331            .ok_or_else(invalid)?;
332        match raw {
333            RawOutput::Screen { categories, .. }
334                if categories.iter().collect::<BTreeSet<_>>().len() == categories.len()
335                    && categories.iter().all(|c| declared.contains(c)) =>
336            {
337                Ok(())
338            }
339            _ => Err(invalid()),
340        }
341    }
342
343    fn supply_screen(
344        &mut self,
345        step: &str,
346        instance: &[String],
347        supplied: Supplied,
348    ) -> Result<(), GenerationError> {
349        use rustev_contract::screening::{
350            ScreenConsequence, ScreenVerdict, ScreeningEvidence, screen_text_digest,
351        };
352        let supplied = match supplied {
353            Supplied::Output(raw) => match self.screen_check(step, &raw) {
354                Ok(()) => Supplied::Output(raw),
355                Err(u) => Supplied::Failed(u),
356            },
357            other => other,
358        };
359        let (_, binding) = output_screen_binding(&self.compiled.plan, step)
360            .ok_or_else(|| bad("unbound screener"))?;
361        let item = self
362            .items
363            .iter_mut()
364            .find(|i| {
365                i.screens
366                    .iter()
367                    .any(|r| r.document.step == step && r.document.instance == instance)
368            })
369            .ok_or_else(|| bad("screen request is not pending"))?;
370        let index = item
371            .screens
372            .iter()
373            .position(|r| r.document.step == step)
374            .ok_or_else(|| bad("missing screen"))?;
375        let request = item.screens.remove(index);
376        let name = &request.document.output.fields[0].name;
377        let Some(TextValue::Text(text)) = item.result.generated[name].value.as_ref() else {
378            return Err(bad("missing screened text"));
379        };
380        let digest = screen_text_digest(text);
381        let (verdict, mut categories, consequence, reason) = match supplied {
382            Supplied::Output(RawOutput::Screen {
383                blocked: false,
384                categories,
385                ..
386            }) => (
387                ScreenVerdict::Pass,
388                categories,
389                ScreenConsequence::None,
390                None,
391            ),
392            Supplied::Output(RawOutput::Screen { categories, .. }) => (
393                ScreenVerdict::Blocked,
394                categories,
395                ScreenConsequence::DropField,
396                Some(TextReason::ScreenBlocked),
397            ),
398            Supplied::Failed(reason) => (
399                ScreenVerdict::Unavailable {
400                    reason: match reason {
401                        Unresolved::BackendUnavailable { .. } => Unresolved::BackendUnavailable {
402                            detail: "output screener unavailable".into(),
403                        },
404                        Unresolved::InvalidBackendOutput { .. } => {
405                            Unresolved::InvalidBackendOutput {
406                                detail: "invalid output screening verdict".into(),
407                            }
408                        }
409                        other => other,
410                    },
411                },
412                vec![],
413                ScreenConsequence::FailClosed,
414                Some(TextReason::ScreenUnavailable),
415            ),
416            _ => return Err(bad("unchecked screen verdict")),
417        };
418        categories.sort();
419        item.screening.insert(
420            name.clone(),
421            ScreeningEvidence {
422                entry: item.step.clone(),
423                field: format!("output:{}/{name}", item.step),
424                item: item.result.id.first().cloned(),
425                text_digest: digest,
426                verdict,
427                categories,
428                consequence,
429                backend_id: binding.backend_id.clone(),
430                artifact: binding.artifact.clone(),
431                descriptor: binding.descriptor.clone(),
432                step: step.into(),
433            },
434        );
435        if let Some(reason) = reason {
436            // A screening verdict is terminal, including a block. Never repair it.
437            item.result
438                .generated
439                .insert(name.clone(), fallback_field(item, name, reason));
440        }
441        Ok(())
442    }
443
444    fn find(&self, step: &str, instance: &[String]) -> Option<&Item> {
445        self.items.iter().find(|i| {
446            i.step == step
447                && i.pending
448                    .as_ref()
449                    .is_some_and(|r| r.document.instance == instance)
450        })
451    }
452
453    /// Record-level validation for runtime retry/fallback decisions. Declared
454    /// invalid fields are accepted at this boundary and handled by repair.
455    pub fn check_output(
456        &self,
457        step: &str,
458        instance: &[String],
459        raw: &RawOutput,
460    ) -> Result<Result<(), Unresolved>, SupplyError> {
461        if self.find_screen(step, instance).is_some() {
462            return Ok(self.screen_check(step, raw));
463        }
464        let item = self
465            .find(step, instance)
466            .ok_or_else(|| SupplyError::NotPending {
467                step: step.into(),
468                instance: instance.to_vec(),
469            })?;
470        Ok(record_check(item, raw))
471    }
472
473    /// Validate an attempt without resolving its request. Ownership notices
474    /// survive a transport retry or fallback that later returns a valid record.
475    pub fn check_attempt_output(
476        &mut self,
477        step: &str,
478        instance: &[String],
479        raw: &RawOutput,
480    ) -> Result<Result<(), Unresolved>, SupplyError> {
481        let checked = self.check_output(step, instance, raw)?;
482        if checked.is_err()
483            && let Some(item) = self.find(step, instance)
484            && let RawOutput::TextRecord(fields) = raw
485        {
486            let notices: Vec<_> = fields
487                .keys()
488                .filter(|n| !item.declaration.output.fields.iter().any(|f| &f.name == *n))
489                .map(|name| TextNotice {
490                    kind: TextNoticeKind::OwnershipViolation,
491                    step: step.into(),
492                    instance: item.result.id.clone(),
493                    field: name.clone(),
494                })
495                .collect();
496            for notice in notices {
497                if !self.notices.contains(&notice) {
498                    self.notices.push(notice);
499                }
500            }
501        }
502        Ok(checked)
503    }
504
505    /// One scripted supply, with deterministic local attempt lineage.
506    pub fn supply(
507        &mut self,
508        step: &str,
509        instance: &[String],
510        supplied: Supplied,
511    ) -> Result<(), GenerationError> {
512        self.supply_attempt(
513            step,
514            instance,
515            supplied,
516            &format!("{step}:{instance:?}:1"),
517            1,
518        )
519    }
520
521    /// Supply the final runtime outcome of a request. `attempts` includes
522    /// transport retries and fallbacks, not earlier repair rounds.
523    pub fn supply_attempt(
524        &mut self,
525        step: &str,
526        instance: &[String],
527        supplied: Supplied,
528        attempt: &str,
529        attempts: u32,
530    ) -> Result<(), GenerationError> {
531        if attempt.is_empty() || attempts == 0 {
532            return Err(bad("attempt identity and count required"));
533        }
534        if self.find_screen(step, instance).is_some() {
535            return self.supply_screen(step, instance, supplied);
536        }
537        let failure = if let Supplied::Failed(u) = &supplied {
538            Some(failure_reason(u, attempts)?)
539        } else {
540            None
541        };
542        let item = self
543            .items
544            .iter_mut()
545            .find(|i| {
546                i.step == step
547                    && i.pending
548                        .as_ref()
549                        .is_some_and(|r| r.document.instance == instance)
550            })
551            .ok_or_else(|| bad("request is not pending"))?;
552        let request = item
553            .pending
554            .as_ref()
555            .ok_or_else(|| bad("request is not pending"))?
556            .clone();
557        if let Some(reason) = failure {
558            complete(item, reason);
559            return Ok(());
560        }
561        let Supplied::Output(raw) = supplied else {
562            return Err(bad("missing supply"));
563        };
564        if record_check(item, &raw).is_err() {
565            if let RawOutput::TextRecord(fields) = &raw {
566                for name in fields
567                    .keys()
568                    .filter(|n| !item.declaration.output.fields.iter().any(|f| &f.name == *n))
569                {
570                    self.notices.push(TextNotice {
571                        kind: TextNoticeKind::OwnershipViolation,
572                        step: step.into(),
573                        instance: item.result.id.clone(),
574                        field: name.clone(),
575                    });
576                }
577            }
578            complete(item, TextReason::InvalidOutput);
579            return Ok(());
580        }
581        let RawOutput::TextRecord(fields) = raw else {
582            return Err(bad("record check disagreed"));
583        };
584        let mut unavailable_screens = Vec::new();
585        let mut invalid = Vec::new();
586        for field in &item.declaration.output.fields {
587            if item.result.generated.contains_key(&field.name) {
588                continue;
589            }
590            match validate_field(
591                field,
592                fields.get(&field.name),
593                &request.document.vocabularies,
594            ) {
595                Ok(value) => {
596                    item.result.generated.insert(
597                        field.name.clone(),
598                        TextField {
599                            origin: if item.round == 0 {
600                                FieldOrigin::Generated
601                            } else {
602                                FieldOrigin::Repaired
603                            },
604                            truncated: false,
605                            source: TextSource::Fresh,
606                            value: Some(value.clone()),
607                            derivation: Some(Derivation::ModelDerived),
608                            lineage: Some(TextLineage::Model {
609                                step: step.into(),
610                                template: request.document.template_id.clone(),
611                                projection: item.declaration.projection.clone(),
612                                attempt: attempt.into(),
613                            }),
614                        },
615                    );
616                    if matches!(field.ty, GeneratedType::Text { .. })
617                        && matches!(
618                            item.declaration.screen.output,
619                            ScreenOutput::Screener { .. }
620                        )
621                    {
622                        let screen_step = output_screen_step(step, &field.name);
623                        let (_, b) = output_screen_binding(&self.compiled.plan, &screen_step)
624                            .ok_or_else(|| bad("unbound output screener"))?;
625                        let TextValue::Text(text) = value else {
626                            return Err(bad("invalid text"));
627                        };
628                        let mut document = request.document.clone();
629                        document.step = screen_step;
630                        document.instance = item.result.id.clone();
631                        document.repair = RepairRequest::None;
632                        document.output.fields = vec![field.clone()];
633                        if self.remaining == 0 {
634                            unavailable_screens
635                                .push((document.step.clone(), document.instance.clone()));
636                        }
637                        item.screens.push(GenerationRequest {
638                            backend_id: b.backend_id.clone(),
639                            owner: step.into(),
640                            document,
641                            screen_projection: Some(
642                                canonical_bytes(&crate::compile::screening::projection(
643                                    serde_json::json!(text),
644                                ))
645                                .map_err(bad)?,
646                            ),
647                        });
648                        self.remaining = self.remaining.saturating_sub(1);
649                    }
650                }
651                Err(reason) => invalid.push(RepairField {
652                    name: field.name.clone(),
653                    reason,
654                }),
655            }
656        }
657        if invalid.is_empty() {
658            item.pending = None;
659        } else if item.round < item.declaration.repair.max_attempts && self.remaining == 0 {
660            complete(item, TextReason::BudgetExhausted);
661        } else if item.round < item.declaration.repair.max_attempts {
662            self.remaining -= 1;
663            item.round += 1;
664            let mut next = request;
665            next.document.instance = item.result.id.clone();
666            next.document
667                .instance
668                .push(format!("repair:{}", item.round));
669            // Reasons name only invalid fields even when the schema requests the
670            // full record. Previously accepted values are never sent back.
671            next.document.repair = RepairRequest::Round {
672                round: item.round,
673                fields: invalid.clone(),
674            };
675            next.document.output = item.declaration.output.clone();
676            if item.declaration.repair.only_missing_fields {
677                next.document
678                    .output
679                    .fields
680                    .retain(|f| invalid.iter().any(|i| i.name == f.name));
681            }
682            item.pending = Some(next);
683        } else {
684            complete(item, TextReason::InvalidOutput);
685        }
686        for (step, instance) in unavailable_screens {
687            self.supply_screen(
688                &step,
689                &instance,
690                Supplied::Failed(Unresolved::BudgetExhausted {
691                    resource: "max_semantic_requests".into(),
692                }),
693            )?;
694        }
695        Ok(())
696    }
697
698    /// Mark only slots supplied by this cached attempt, preserving their origin.
699    /// A later repair or backstop does not inherit another attempt's delivery.
700    pub fn note_cached_attempt(&mut self, attempt: &str, decision: &str) {
701        for item in &mut self.items {
702            for field in item.result.generated.values_mut() {
703                if matches!(&field.lineage, Some(TextLineage::Model { attempt: a, .. }) if a == attempt)
704                {
705                    field.source = TextSource::ServedFromCache {
706                        original_decision_id: decision.into(),
707                    };
708                }
709            }
710        }
711    }
712
713    /// Runtime withholding (including missing output) ends only unresolved
714    /// slots. It never replaces a valid field or changes the judgment.
715    pub fn withhold(
716        &mut self,
717        step: &str,
718        instance: &[String],
719        reason: TextReason,
720    ) -> Result<(), GenerationError> {
721        let item = self
722            .items
723            .iter_mut()
724            .find(|i| {
725                i.step == step
726                    && i.pending
727                        .as_ref()
728                        .is_some_and(|r| r.document.instance == instance)
729            })
730            .ok_or_else(|| bad("request is not pending"))?;
731        complete(item, reason);
732        Ok(())
733    }
734
735    /// Restore source-owned values in a host copy before publication. Returns
736    /// notices in the text result, never in the judgment. The source is private.
737    pub fn verify_ownership(&self, judgment: &mut Judgment, text: &mut TextResult) {
738        if judgment != &self.judgment {
739            *judgment = self.judgment.clone();
740            text.notices.push(TextNotice {
741                kind: TextNoticeKind::OwnershipRestored,
742                step: String::new(),
743                instance: vec![],
744                field: "judgment".into(),
745            });
746        }
747        for step in &mut text.steps {
748            for item in &mut step.items {
749                if let Some(source) = self
750                    .items
751                    .iter()
752                    .find(|i| i.step == step.step && i.result.id == item.id)
753                {
754                    let names: BTreeSet<_> = item
755                        .context
756                        .keys()
757                        .chain(source.result.context.keys())
758                        .cloned()
759                        .collect();
760                    for name in names {
761                        if item.context.get(&name) != source.result.context.get(&name) {
762                            text.notices.push(TextNotice {
763                                kind: TextNoticeKind::OwnershipRestored,
764                                step: step.step.clone(),
765                                instance: item.id.clone(),
766                                field: name,
767                            });
768                        }
769                    }
770                    item.context = source.result.context.clone();
771                }
772            }
773        }
774    }
775
776    pub fn finish(
777        &self,
778    ) -> Result<(Judgment, EvidenceRecord, Option<TextResult>), GenerationError> {
779        let pending = self.pending().len();
780        if pending != 0 {
781            return Err(GenerationError::NotReady(NotReady { pending }));
782        }
783        if self.steps.is_empty() {
784            return Ok((self.judgment.clone(), self.evidence.clone(), None));
785        }
786        let mut text = TextResult {
787            schema: schema::TEXT_RESULT.into(),
788            decision_id: self.evidence.decision_id.clone(),
789            plan_id: self.compiled.id.clone(),
790            steps: self.steps.clone(),
791            text_origin: TextOrigin::None,
792            counts: TextCounts::default(),
793            notices: self.notices.clone(),
794        };
795        for step in &mut text.steps {
796            step.items = self
797                .items
798                .iter()
799                .filter(|i| i.step == step.step)
800                .map(|i| i.result.clone())
801                .collect();
802            for item in &step.items {
803                for (name, field) in &item.generated {
804                    if field.truncated {
805                        text.notices.push(TextNotice {
806                            kind: TextNoticeKind::BackstopTruncated,
807                            step: step.step.clone(),
808                            instance: item.id.clone(),
809                            field: name.clone(),
810                        });
811                    }
812                }
813            }
814            (step.text_origin, step.counts) =
815                summarize(step.items.iter().flat_map(|i| i.generated.values()));
816        }
817        (text.text_origin, text.counts) = summarize(
818            text.steps
819                .iter()
820                .flat_map(|s| s.items.iter().flat_map(|i| i.generated.values())),
821        );
822        let mut judgment = self.judgment.clone();
823        self.verify_ownership(&mut judgment, &mut text);
824        let mut evidence_record = self.evidence.clone();
825        let screening: Vec<_> = self
826            .items
827            .iter()
828            .flat_map(|i| i.screening.values().cloned())
829            .collect();
830        if !screening.is_empty() {
831            evidence_record
832                .screening
833                .get_or_insert_with(Vec::new)
834                .extend(screening);
835        }
836        evidence_record.generation = Some(
837            text.steps
838                .iter()
839                .map(|s| evidence(s, &text.notices))
840                .collect::<Result<_, _>>()?,
841        );
842        Ok((judgment, evidence_record, Some(text)))
843    }
844}
845
846fn failure_reason(u: &Unresolved, attempts: u32) -> Result<TextReason, GenerationError> {
847    Ok(match u {
848        Unresolved::InvalidBackendOutput { .. } => TextReason::InvalidOutput,
849        Unresolved::DeadlineExceeded => TextReason::Deadline,
850        Unresolved::BudgetExhausted { .. } => TextReason::BudgetExhausted,
851        Unresolved::BackendUnavailable { .. } if attempts >= 2 => TextReason::RetriesExhausted,
852        Unresolved::BackendUnavailable { .. } => TextReason::BackendUnavailable,
853        _ => return Err(bad("not a runtime failure")),
854    })
855}
856fn record_check(item: &Item, raw: &RawOutput) -> Result<(), Unresolved> {
857    let RawOutput::TextRecord(fields) = raw else {
858        return Err(Unresolved::InvalidBackendOutput {
859            detail: "expected text_record".into(),
860        });
861    };
862    let Some(request) = &item.pending else {
863        return Err(Unresolved::InvalidBackendOutput {
864            detail: "no pending request".into(),
865        });
866    };
867    if fields
868        .keys()
869        .any(|n| !request.document.output.fields.iter().any(|f| &f.name == n))
870    {
871        return Err(Unresolved::InvalidBackendOutput {
872            detail: "undeclared or unrequested member".into(),
873        });
874    }
875    Ok(())
876}
877/// Field-level validation preserves valid bytes exactly.
878pub fn validate_field<'a>(
879    field: &GeneratedField,
880    value: Option<&'a TextValue>,
881    vocabularies: &BTreeMap<String, Vec<String>>,
882) -> Result<&'a TextValue, FieldInvalid> {
883    let value = value.ok_or(FieldInvalid::Missing)?;
884    match (&field.ty, value) {
885        (GeneratedType::Text { max_bytes, lines }, TextValue::Text(s)) => {
886            if s.is_empty() {
887                return Err(FieldInvalid::Empty);
888            }
889            if s.len() as u64 > *max_bytes {
890                return Err(FieldInvalid::TooLong);
891            }
892            if s.chars()
893                .any(|c| c.is_control() && !(c == '\n' && *lines == Lines::Multi))
894            {
895                return Err(FieldInvalid::ControlCharacter);
896            }
897        }
898        (
899            GeneratedType::List {
900                vocabulary,
901                max_items,
902            },
903            TextValue::List(v),
904        ) => {
905            if field.required && v.is_empty() {
906                return Err(FieldInvalid::Empty);
907            }
908            if v.len() as u64 > *max_items {
909                return Err(FieldInvalid::TooManyItems);
910            }
911            let options = vocabularies
912                .get(vocabulary)
913                .ok_or(FieldInvalid::NotInVocabulary)?;
914            if v.iter().any(|s| !options.contains(s)) {
915                return Err(FieldInvalid::NotInVocabulary);
916            }
917            if v.iter().collect::<BTreeSet<_>>().len() != v.len() {
918                return Err(FieldInvalid::DuplicateItem);
919            }
920        }
921        _ => return Err(FieldInvalid::WrongType),
922    }
923    Ok(value)
924}
925fn fallback_field(item: &Item, name: &str, reason: TextReason) -> TextField {
926    let mut slot = item.backstops.get(name).cloned().unwrap_or(TextField {
927        origin: FieldOrigin::Absent { reason },
928        truncated: false,
929        source: TextSource::Fresh,
930        value: None,
931        derivation: None,
932        lineage: None,
933    });
934    if slot.value.is_some() {
935        slot.origin = FieldOrigin::Backstop { reason };
936    }
937    slot
938}
939fn complete(item: &mut Item, reason: TextReason) {
940    for field in &item.declaration.output.fields {
941        let fallback = fallback_field(item, &field.name, reason);
942        item.result
943            .generated
944            .entry(field.name.clone())
945            .or_insert(fallback);
946    }
947    item.pending = None;
948}
949fn summarize<'a>(fields: impl Iterator<Item = &'a TextField>) -> (TextOrigin, TextCounts) {
950    let mut c = TextCounts::default();
951    for f in fields {
952        match f.origin {
953            FieldOrigin::Generated => c.generated += 1,
954            FieldOrigin::Repaired => c.repaired += 1,
955            FieldOrigin::Backstop { reason } => {
956                c.backstop += 1;
957                *c.reasons.entry(reason).or_default() += 1;
958            }
959            FieldOrigin::Absent { reason } => {
960                c.absent += 1;
961                *c.reasons.entry(reason).or_default() += 1;
962            }
963        }
964    }
965    let origin = if c.generated + c.repaired + c.backstop == 0 {
966        TextOrigin::None
967    } else if c.backstop + c.absent == 0 {
968        TextOrigin::Generated
969    } else if c.generated + c.repaired == 0 {
970        TextOrigin::Backstop
971    } else {
972        TextOrigin::Partial
973    };
974    (origin, c)
975}
976fn digest<T: serde::Serialize>(value: &T) -> Result<ContentDigest, GenerationError> {
977    rustev_contract::generation_result::generation_value_digest(value).map_err(bad)
978}
979fn evidence(
980    step: &TextStep,
981    notices: &[TextNotice],
982) -> Result<GenerationEvidence, GenerationError> {
983    Ok(GenerationEvidence {
984        step: step.step.clone(),
985        state: step.state.clone(),
986        text_origin: step.text_origin,
987        counts: step.counts.clone(),
988        notices: notices
989            .iter()
990            // Spec 037, 3.9.3: step-less judgment restoration belongs once in
991            // TextResult.notices, not in each step's generation evidence.
992            .filter(|n| n.step == step.step)
993            .cloned()
994            .collect(),
995        items: step
996            .items
997            .iter()
998            .map(|i| {
999                Ok(GenerationItemEvidence {
1000                    id: i.id.clone(),
1001                    context: i
1002                        .context
1003                        .iter()
1004                        .map(|(n, v)| Ok((n.clone(), digest(&v.value)?)))
1005                        .collect::<Result<_, GenerationError>>()?,
1006                    generated: i
1007                        .generated
1008                        .iter()
1009                        .map(|(n, f)| {
1010                            Ok((
1011                                n.clone(),
1012                                TextFieldEvidence {
1013                                    origin: f.origin.clone(),
1014                                    truncated: f.truncated,
1015                                    source: f.source.clone(),
1016                                    value_digest: f.value.as_ref().map(digest).transpose()?,
1017                                    byte_length: f.value.as_ref().map(|v| match v {
1018                                        TextValue::Text(s) => s.len() as u64,
1019                                        TextValue::List(v) => {
1020                                            v.iter().map(|s| s.len() as u64).sum()
1021                                        }
1022                                    }),
1023                                },
1024                            ))
1025                        })
1026                        .collect::<Result<_, GenerationError>>()?,
1027                })
1028            })
1029            .collect::<Result<_, GenerationError>>()?,
1030    })
1031}
1032
1033fn reference<'a>(r: &str, roots: &'a BTreeMap<String, Value>) -> Result<&'a Value, Unresolved> {
1034    let missing = || Unresolved::missing([r.to_string()]);
1035    let (root, rest) = r.split_once(':').ok_or_else(missing)?;
1036    let mut parts = rest.split('/');
1037    let mut value = if root == "bind" {
1038        roots
1039            .get(parts.next().ok_or_else(missing)?)
1040            .ok_or_else(missing)?
1041    } else {
1042        roots.get(root).ok_or_else(missing)?
1043    };
1044    for part in parts {
1045        let Value::Record(fields) = value else {
1046            return Err(missing());
1047        };
1048        value = fields.get(part).ok_or_else(missing)?;
1049    }
1050    if matches!(value, Value::Null | Value::Absent(_)) {
1051        return Err(missing());
1052    }
1053    Ok(value)
1054}
1055fn from_out(v: &OutValue) -> Result<Value, GenerationError> {
1056    Ok(match v {
1057        OutValue::Attributed { value, .. } => return from_out(value),
1058        OutValue::Bool(v) => Value::Bool(*v),
1059        OutValue::Integer(v) => Value::Integer(*v),
1060        OutValue::Decimal(v) => Value::Decimal(*v),
1061        OutValue::Text(v) => Value::Text(v.clone()),
1062        OutValue::Enum(v) => Value::Enum(v.clone()),
1063        OutValue::Timestamp(v) => Value::Timestamp(*v),
1064        OutValue::Duration(v) => Value::Duration(*v),
1065        OutValue::None => Value::Maybe(None),
1066        OutValue::List(v) => Value::List(v.iter().map(from_out).collect::<Result<_, _>>()?),
1067        OutValue::Record(v) => Value::Record(
1068            v.iter()
1069                .map(|(n, v)| Ok((n.clone(), from_out(v)?)))
1070                .collect::<Result<_, GenerationError>>()?,
1071        ),
1072        OutValue::Filtered(v) => Value::Filtered(v.clone()),
1073        OutValue::Shortlist(v) => Value::Shortlist(v.clone()),
1074        OutValue::Ranking(v) => Value::Ranking(v.clone()),
1075        OutValue::Selection(_) => {
1076            return Err(GenerationError::InvalidState(
1077                "selection is not a generation input".into(),
1078            ));
1079        }
1080    })
1081}
1082// Spec 037 3.3.3, 3.4.3 and 3.4.5: the compiler refuses a ranking or
1083// duration template read (23 `template`), so the `None` arms are unreachable.
1084pub(crate) fn template_value(v: &Value) -> Option<t::Value> {
1085    Some(match v {
1086        Value::Bool(v) => t::Value::Bool(*v),
1087        Value::Integer(v) => t::Value::Integer(*v),
1088        Value::Decimal(v) => t::Value::Decimal(*v),
1089        Value::Text(v) => t::Value::Text(v.clone()),
1090        Value::Enum(v) => t::Value::Enum(v.clone()),
1091        Value::Timestamp(v) => t::Value::Timestamp(*v),
1092        Value::Maybe(v) => t::Value::Maybe(match v {
1093            Some(v) => Some(Box::new(template_value(v)?)),
1094            None => None,
1095        }),
1096        Value::Null | Value::Absent(_) => t::Value::Maybe(None),
1097        Value::List(v) => t::Value::List(v.iter().map(template_value).collect::<Option<_>>()?),
1098        Value::Record(v) => t::Value::Record(
1099            v.iter()
1100                .filter_map(|(n, v)| template_value(v).map(|v| (n.clone(), v)))
1101                .collect(),
1102        ),
1103        Value::Filtered(_) | Value::Shortlist(_) => {
1104            t::Value::List(v.ids()?.into_iter().map(t::Value::Text).collect())
1105        }
1106        _ => return None,
1107    })
1108}
1109fn make_items(
1110    id: &str,
1111    g: &Generate,
1112    checked: &Checked,
1113    roots: &BTreeMap<String, Value>,
1114    lineage: &BTreeMap<String, Lineage>,
1115) -> Result<Vec<Item>, Unresolved> {
1116    let invalid = || Unresolved::invalid([], "generation source cannot be rendered");
1117    let instances = match &g.for_each {
1118        ModelForEach::None => vec![vec![]],
1119        ModelForEach::List(f) => reference(&f.over, roots)?
1120            .ids()
1121            .ok_or_else(invalid)?
1122            .into_iter()
1123            .map(|id| vec![id])
1124            .collect(),
1125    };
1126    let mut items = Vec::new();
1127    for (index, instance) in instances.into_iter().enumerate() {
1128        let mut roots = roots.clone();
1129        if let ModelForEach::List(f) = &g.for_each {
1130            let item_id = instance.first().ok_or_else(invalid)?;
1131            let value = match &f.items {
1132                ItemSource::None => Value::Text(item_id.clone()),
1133                ItemSource::List { list, id_field } => {
1134                    let Value::List(values) = reference(list, &roots)? else {
1135                        return Err(invalid());
1136                    };
1137                    values.iter().find(|v| matches!(v, Value::Record(m) if matches!(m.get(id_field), Some(Value::Text(s) | Value::Enum(s)) if s == item_id))).cloned().ok_or_else(invalid)?
1138                }
1139            };
1140            roots.insert(f.bind.clone(), value);
1141        }
1142        let mut params = BTreeMap::new();
1143        for (name, binding) in &g.template.params {
1144            let value = match binding {
1145                TemplateBinding::Reference(r) => reference(r, &roots)?.clone(),
1146                TemplateBinding::Literal { literal } => Value::from_literal(literal),
1147                TemplateBinding::Parameter(_) => return Err(invalid()),
1148            };
1149            params.insert(name.clone(), template_value(&value).ok_or_else(invalid)?);
1150        }
1151        let instructions = checked
1152            .prompt
1153            .as_ref()
1154            .ok_or_else(invalid)?
1155            .render(&BTreeMap::from([(
1156                "param".into(),
1157                t::Value::Record(params),
1158            )]))
1159            .map_err(|_| invalid())?
1160            .text;
1161        let values = roots
1162            .iter()
1163            .filter_map(|(n, v)| template_value(v).map(|v| (n.clone(), v)))
1164            .collect();
1165        let mut backstops = BTreeMap::new();
1166        for (name, backstop) in &g.backstop.fields {
1167            let (value, truncated, paths) = match backstop {
1168                BackstopField::Text(_) => {
1169                    let template = checked.backstops.get(name).ok_or_else(invalid)?;
1170                    let rendered = template.render(&values).map_err(|_| invalid())?;
1171                    (
1172                        TextValue::Text(rendered.text),
1173                        rendered.truncated,
1174                        rendered.reads.into_iter().collect(),
1175                    )
1176                }
1177                BackstopField::List(v) => (TextValue::List(v.clone()), false, vec![]),
1178            };
1179            backstops.insert(
1180                name.clone(),
1181                TextField {
1182                    origin: FieldOrigin::Backstop {
1183                        reason: TextReason::NotAttempted,
1184                    },
1185                    value: Some(value),
1186                    truncated,
1187                    source: TextSource::Fresh,
1188                    derivation: Some(Derivation::ExactDerived),
1189                    lineage: Some(TextLineage::Backstop { paths }),
1190                },
1191            );
1192        }
1193        let mut context = BTreeMap::new();
1194        for r in &g.carry {
1195            let value = reference(r, &roots)?;
1196            let source = if r.starts_with("bind:") {
1197                match &g.for_each {
1198                    ModelForEach::List(f) => match &f.items {
1199                        ItemSource::List { list, .. } => list.as_str(),
1200                        ItemSource::None => f.over.as_str(),
1201                    },
1202                    ModelForEach::None => r.as_str(),
1203                }
1204            } else {
1205                r.as_str()
1206            };
1207            let base = crate::expr::base_ref_v2(source);
1208            let l = lineage.get(&base).cloned().unwrap_or_else(|| Lineage {
1209                producers: None,
1210                inputs: base
1211                    .strip_prefix("input:")
1212                    .map(String::from)
1213                    .into_iter()
1214                    .collect(),
1215                steps: base
1216                    .strip_prefix("step:")
1217                    .map(String::from)
1218                    .into_iter()
1219                    .collect(),
1220            });
1221            let name = r.rsplit(['/', ':']).next().ok_or_else(invalid)?;
1222            context.insert(
1223                name.into(),
1224                ContextValue {
1225                    value: value.to_out(),
1226                    derivation: Derivation::ExactDerived,
1227                    lineage: l,
1228                },
1229            );
1230        }
1231        let projection = g
1232            .projection
1233            .iter()
1234            .map(|r| Ok((r.clone(), reference(r, &roots)?.to_json())))
1235            .collect::<Result<_, Unresolved>>()?;
1236        let Language::Literal(language) = &g.language else {
1237            return Err(invalid());
1238        };
1239        let mut item = Item {
1240            step: id.into(),
1241            declaration: g.clone(),
1242            result: TextItem {
1243                id: instance.clone(),
1244                context,
1245                generated: BTreeMap::new(),
1246            },
1247            backstops,
1248            round: 0,
1249            screens: vec![],
1250            screening: BTreeMap::new(),
1251            pending: Some(GenerationRequest {
1252                owner: id.into(),
1253                screen_projection: None,
1254                backend_id: checked.binding.backend_id.clone(),
1255                document: GenerateRequest {
1256                    schema: schema::GENERATE_REQUEST.into(),
1257                    step: id.into(),
1258                    instance,
1259                    template: checked.binding.template.id.clone(),
1260                    template_id: checked.binding.template_id.clone(),
1261                    instructions,
1262                    language: language.clone(),
1263                    projection,
1264                    output: g.output.clone(),
1265                    vocabularies: checked.binding.vocabularies.clone(),
1266                    repair: RepairRequest::None,
1267                },
1268            }),
1269        };
1270        if index as u64 >= checked.binding.max_items {
1271            complete(&mut item, TextReason::NotAttempted);
1272        }
1273        items.push(item);
1274    }
1275    Ok(items)
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281
1282    fn fixture() -> (
1283        rustev_contract::plan::Plan,
1284        rustev_contract::descriptor::BackendDescriptor,
1285    ) {
1286        use rustev_contract::definition::{Determinism, Operation};
1287        use rustev_contract::descriptor::{
1288            BackendDescriptor, InputExcess, InputLimit, OperationSupport,
1289        };
1290        let plan = serde_json::from_str(include_str!("testdata/generation.plan.json")).unwrap();
1291        let backend = BackendDescriptor {
1292            ranking: None,
1293            schema: schema::BACKEND.into(),
1294            backend_id: "synthetic-linear-head".into(),
1295            artifact: rustev_contract::ids::ArtifactId::parse(&format!(
1296                "sha256:{}",
1297                "a".repeat(64)
1298            ))
1299            .unwrap(),
1300            operations: vec![OperationSupport {
1301                isolation: None,
1302                screen: None,
1303                operation: Operation::Generate,
1304                output: OutputKind::TextRecord {
1305                    structure: TextStructure::JsonSchema,
1306                    max_output_bytes: 4096,
1307                    languages: vec!["en-GB".into()],
1308                    streaming: false,
1309                },
1310                max_options: 32,
1311            }],
1312            input_limit: InputLimit {
1313                max_bytes: 65_536,
1314                on_excess: InputExcess::Refuse,
1315            },
1316            determinism: Determinism::Tolerance,
1317        };
1318        (plan, backend)
1319    }
1320
1321    #[test]
1322    fn ranking_and_duration_proposal_template_reads_are_category_23() {
1323        use crate::{expr::StepTy, value::Ty};
1324        use rustev_contract::definition::{OutcomeDecl, ParamDecl, ParamSource};
1325        for ty in [Ty::Integer, Ty::Ranking { max: 4 }, Ty::Duration] {
1326            for prompt_binding in [false, true] {
1327                let (mut plan, _) = fixture();
1328                let PlanStepDetail::Generate(binding) = &plan.steps[0].detail else {
1329                    panic!()
1330                };
1331                let mut prompt = binding.template.clone();
1332                let OutcomeDecl::Propose { params, .. } = &mut plan.definition.policy.rules[0].then
1333                else {
1334                    panic!()
1335                };
1336                params.push(ParamDecl {
1337                    name: "value".into(),
1338                    value: ParamSource::Ref("step:source".into()),
1339                });
1340                let mut source = plan.definition.steps[0].clone();
1341                source.id = "source".into();
1342                let StepBody::Generate(g) = &mut plan.definition.steps[0].body else {
1343                    panic!()
1344                };
1345                if prompt_binding {
1346                    prompt.params = vec![PromptParam {
1347                        name: "value".into(),
1348                        ty: PromptParamType::Integer,
1349                    }];
1350                    prompt.instructions = "Value: {{param.value}}".into();
1351                    g.template.params.insert(
1352                        "value".into(),
1353                        TemplateBinding::Reference("proposal:value".into()),
1354                    );
1355                } else {
1356                    g.backstop.fields.insert(
1357                        "headline".into(),
1358                        BackstopField::Text("Value: {{proposal.value}}".into()),
1359                    );
1360                }
1361                // The type-checking phase consumes the exact source's inferred type.
1362                source.body = StepBody::Exact(rustev_contract::definition::ExactDecl {
1363                    op: "count".into(),
1364                    version: 1,
1365                    args: serde_json::json!({"list":"input:items"}),
1366                });
1367                plan.definition.steps.push(source);
1368                let result = crate::generation::type_check(
1369                    &plan.definition,
1370                    &BTreeMap::from([("item_text".into(), prompt)]),
1371                    &BTreeMap::new(),
1372                    &BTreeMap::from([("source".into(), StepTy::Exact(ty.clone()))]),
1373                    &BTreeMap::from([("source".into(), Derivation::ExactDerived)]),
1374                    &BTreeMap::new(),
1375                );
1376                if ty == Ty::Integer {
1377                    assert!(result.is_ok(), "integer control: {:?}", result.err());
1378                } else {
1379                    let Err(error) = result else {
1380                        panic!("unsupported template read must fail")
1381                    };
1382                    assert_eq!(error.category, crate::Category::Template);
1383                    assert_eq!(error.subject, "step:item_text");
1384                }
1385            }
1386        }
1387    }
1388
1389    #[test]
1390    fn repair_output_budget_covers_only_requested_fields() {
1391        use rustev_contract::scope::Handle;
1392        use rustev_contract::time::Timestamp;
1393        for only_missing in [true, false] {
1394            let (mut plan, backend) = fixture();
1395            let PlanStepDetail::Generate(binding) = &plan.steps[0].detail else {
1396                panic!()
1397            };
1398            let prompt = binding.template.clone();
1399            let StepBody::Generate(g) = &mut plan.definition.steps[0].body else {
1400                panic!()
1401            };
1402            g.repair.only_missing_fields = only_missing;
1403            let compiled = crate::compile_with_templates(
1404                &plan.definition,
1405                &[backend],
1406                &[],
1407                &[],
1408                None,
1409                &[prompt],
1410            )
1411            .unwrap();
1412            let snapshot = rustev_contract::snapshot::Snapshot {
1413                schema: schema::SNAPSHOT.into(),
1414                entries: vec![],
1415            };
1416            let mut stage =
1417                crate::Evaluation::start(&compiled, &snapshot, Timestamp::from_ms(0).unwrap())
1418                    .unwrap()
1419                    .begin_generation("decision")
1420                    .unwrap();
1421            let scope = Scope::tenant_only(
1422                Handle::new("tenant").unwrap(),
1423                Handle::new("revision").unwrap(),
1424            );
1425            let initial = stage.request_document("item_text", &[], 0, &scope).unwrap();
1426            assert!(matches!(
1427                initial.output,
1428                OutputKind::TextRecord {
1429                    max_output_bytes: 548,
1430                    ..
1431                }
1432            ));
1433            stage
1434                .supply(
1435                    "item_text",
1436                    &[],
1437                    Supplied::Output(RawOutput::TextRecord(BTreeMap::from([
1438                        ("headline".into(), TextValue::Text("Keep".into())),
1439                        ("why".into(), TextValue::Text(String::new())),
1440                        ("tags".into(), TextValue::List(vec!["quiet".into()])),
1441                    ]))),
1442                )
1443                .unwrap();
1444            let pending = stage.pending().remove(0);
1445            assert_eq!(pending.document.instance, vec!["repair:1"]);
1446            assert_eq!(
1447                pending.document.output.fields.len(),
1448                if only_missing { 1 } else { 3 }
1449            );
1450            let repair = stage
1451                .request_document("item_text", &pending.document.instance, 0, &scope)
1452                .unwrap();
1453            let OutputKind::TextRecord {
1454                max_output_bytes, ..
1455            } = repair.output
1456            else {
1457                panic!()
1458            };
1459            assert_eq!(max_output_bytes, if only_missing { 400 } else { 548 });
1460        }
1461    }
1462
1463    #[test]
1464    fn evidence_scopes_notices_to_their_generate_step() {
1465        let notices = vec![
1466            TextNotice {
1467                kind: TextNoticeKind::OwnershipRestored,
1468                step: String::new(),
1469                instance: vec![],
1470                field: "judgment".into(),
1471            },
1472            TextNotice {
1473                kind: TextNoticeKind::OwnershipViolation,
1474                step: "first".into(),
1475                instance: vec![],
1476                field: "judgment".into(),
1477            },
1478        ];
1479        for id in ["first", "second"] {
1480            let step = TextStep {
1481                step: id.into(),
1482                state: GenerationState::Ran,
1483                lineage: Lineage::default(),
1484                items: vec![],
1485                text_origin: TextOrigin::None,
1486                counts: TextCounts::default(),
1487            };
1488            let actual = evidence(&step, &notices).unwrap();
1489            let expected = if id == "first" {
1490                vec![notices[1].clone()]
1491            } else {
1492                vec![]
1493            };
1494            assert_eq!(actual.notices, expected);
1495        }
1496    }
1497}