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