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