1use std::collections::{BTreeMap, BTreeSet};
3
4use serde::{Deserialize, Serialize};
5
6use crate::ids::{ContentDigest, PlanId, PromptTemplateId};
7use crate::judgment::{Derivation, Lineage, OutValue, Unresolved};
8use crate::output::TextValue;
9
10#[derive(
11 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
12)]
13#[serde(rename_all = "snake_case")]
14pub enum TextReason {
15 ScreenBlocked,
16 ScreenUnavailable,
17 Deadline,
18 RetriesExhausted,
19 InvalidOutput,
20 BackendUnavailable,
21 BudgetExhausted,
22 NotAttempted,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
26#[serde(rename_all = "snake_case", deny_unknown_fields)]
27pub enum FieldOrigin {
28 Generated,
29 Repaired,
30 Backstop { reason: TextReason },
31 Absent { reason: TextReason },
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
35#[serde(rename_all = "snake_case", deny_unknown_fields)]
36pub enum TextSource {
37 Fresh,
38 ServedFromCache { original_decision_id: String },
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
43#[serde(rename_all = "snake_case", deny_unknown_fields)]
44pub enum TextLineage {
45 Model {
46 step: String,
47 template: PromptTemplateId,
48 projection: Vec<String>,
49 attempt: String,
50 },
51 Backstop {
52 paths: Vec<String>,
53 },
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
57#[serde(deny_unknown_fields)]
58pub struct TextField {
59 pub origin: FieldOrigin,
60 pub truncated: bool,
61 pub source: TextSource,
62 #[serde(
63 default,
64 skip_serializing_if = "Option::is_none",
65 deserialize_with = "crate::screening::present"
66 )]
67 #[schemars(with = "TextValue")]
68 pub value: Option<TextValue>,
69 #[serde(
70 default,
71 skip_serializing_if = "Option::is_none",
72 deserialize_with = "crate::screening::present"
73 )]
74 #[schemars(with = "Derivation")]
75 pub derivation: Option<Derivation>,
76 #[serde(
77 default,
78 skip_serializing_if = "Option::is_none",
79 deserialize_with = "crate::screening::present"
80 )]
81 #[schemars(with = "TextLineage")]
82 pub lineage: Option<TextLineage>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
86#[serde(rename_all = "snake_case", deny_unknown_fields)]
87pub enum GenerationState {
88 Ran,
89 Skipped { reason: GenerationSkipReason },
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
93#[serde(rename_all = "snake_case", deny_unknown_fields)]
94pub enum GenerationSkipReason {
95 OutcomeNotMatched,
96 SourceUnresolved(Unresolved),
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(rename_all = "snake_case")]
101pub enum TextOrigin {
102 None,
103 Generated,
104 Backstop,
105 Partial,
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
109#[serde(deny_unknown_fields)]
110pub struct TextCounts {
111 pub generated: u64,
112 pub repaired: u64,
113 pub backstop: u64,
114 pub absent: u64,
115 pub reasons: BTreeMap<TextReason, u64>,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
119#[serde(deny_unknown_fields)]
120pub struct TextItem {
121 pub id: Vec<String>,
123 pub context: BTreeMap<String, ContextValue>,
124 pub generated: BTreeMap<String, TextField>,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
128#[serde(deny_unknown_fields)]
129pub struct ContextValue {
130 pub value: OutValue,
131 pub derivation: Derivation,
132 pub lineage: Lineage,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
136#[serde(deny_unknown_fields)]
137pub struct TextStep {
138 pub step: String,
139 pub state: GenerationState,
140 pub lineage: Lineage,
142 pub items: Vec<TextItem>,
143 pub text_origin: TextOrigin,
144 pub counts: TextCounts,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
148#[serde(rename_all = "snake_case")]
149pub enum TextNoticeKind {
150 OwnershipViolation,
151 OwnershipRestored,
152 BackstopTruncated,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
156#[serde(deny_unknown_fields)]
157pub struct TextNotice {
158 pub kind: TextNoticeKind,
159 pub step: String,
160 pub instance: Vec<String>,
161 pub field: String,
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
165#[serde(deny_unknown_fields)]
166pub struct TextResult {
167 pub schema: String,
168 pub decision_id: String,
169 pub plan_id: PlanId,
170 pub steps: Vec<TextStep>,
171 pub text_origin: TextOrigin,
172 pub counts: TextCounts,
173 pub notices: Vec<TextNotice>,
174}
175
176crate::document::versioned_document!(
177 TextResult,
178 [crate::schema::TEXT_RESULT],
179 "rustev.text-result/1",
180 crate::limits::RECORD_V1,
181 check = TextResult::validate
182);
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
185#[serde(deny_unknown_fields)]
186pub struct TextFieldEvidence {
187 pub origin: FieldOrigin,
188 pub truncated: bool,
189 pub source: TextSource,
190 #[serde(
192 default,
193 skip_serializing_if = "Option::is_none",
194 deserialize_with = "crate::screening::present"
195 )]
196 #[schemars(with = "u64")]
197 pub byte_length: Option<u64>,
198 #[serde(
199 default,
200 skip_serializing_if = "Option::is_none",
201 deserialize_with = "crate::screening::present"
202 )]
203 #[schemars(with = "ContentDigest")]
204 pub value_digest: Option<ContentDigest>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
208#[serde(deny_unknown_fields)]
209pub struct GenerationItemEvidence {
210 pub id: Vec<String>,
211 pub context: BTreeMap<String, ContentDigest>,
212 pub generated: BTreeMap<String, TextFieldEvidence>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
216#[serde(deny_unknown_fields)]
217pub struct GenerationEvidence {
218 pub step: String,
219 pub state: GenerationState,
220 pub items: Vec<GenerationItemEvidence>,
221 pub text_origin: TextOrigin,
222 pub counts: TextCounts,
223 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub notices: Vec<TextNotice>,
225}
226
227fn slot(origin: &FieldOrigin, truncated: bool, source: &TextSource) -> Result<(), String> {
228 if truncated && !matches!(origin, FieldOrigin::Backstop { .. }) {
229 return Err("only backstop fields may be truncated".into());
230 }
231 if matches!(source, TextSource::ServedFromCache { original_decision_id } if original_decision_id.is_empty())
232 {
233 return Err("cache source requires original_decision_id".into());
234 }
235 Ok(())
236}
237
238fn summary<'a>(
239 origins: impl Iterator<Item = &'a FieldOrigin>,
240 origin: TextOrigin,
241 counts: &TextCounts,
242) -> Result<(), String> {
243 let mut actual = TextCounts::default();
244 for o in origins {
245 match o {
246 FieldOrigin::Generated => actual.generated += 1,
247 FieldOrigin::Repaired => actual.repaired += 1,
248 FieldOrigin::Backstop { reason } => {
249 actual.backstop += 1;
250 *actual.reasons.entry(*reason).or_default() += 1;
251 }
252 FieldOrigin::Absent { reason } => {
253 actual.absent += 1;
254 *actual.reasons.entry(*reason).or_default() += 1;
255 }
256 }
257 }
258 let expected = if actual.generated + actual.repaired + actual.backstop == 0 {
259 TextOrigin::None
260 } else if actual.backstop + actual.absent == 0 {
261 TextOrigin::Generated
262 } else if actual.generated + actual.repaired == 0 {
263 TextOrigin::Backstop
264 } else {
265 TextOrigin::Partial
266 };
267 if expected != origin || &actual != counts {
268 return Err("text origin or counts disagree with slots".into());
269 }
270 Ok(())
271}
272
273fn names<'a>(names: impl Iterator<Item = &'a String>) -> Result<(), String> {
274 for n in names {
275 if n.is_empty()
276 || n.len() > 64
277 || !n.as_bytes()[0].is_ascii_lowercase()
278 || !n
279 .bytes()
280 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
281 {
282 return Err("invalid generated field name".into());
283 }
284 }
285 Ok(())
286}
287
288impl TextResult {
289 fn validate(&self) -> Result<(), String> {
290 if self.steps.is_empty() || self.steps.len() > 8 {
291 return Err("generation requires one to eight steps".into());
292 }
293 let mut steps = BTreeSet::new();
294 for s in &self.steps {
295 if s.step.is_empty() || !steps.insert(&s.step) {
296 return Err("invalid or duplicate generate step".into());
297 }
298 if s.state != GenerationState::Ran && !s.items.is_empty() {
299 return Err("skipped step has items".into());
300 }
301 let mut items = BTreeSet::new();
302 for i in &s.items {
303 if !items.insert(&i.id) {
304 return Err("duplicate text item".into());
305 }
306 if i.context.len() > 16 || i.generated.is_empty() || i.generated.len() > 32 {
307 return Err("text item field bounds".into());
308 }
309 names(i.generated.keys())?;
310 if i.context
311 .values()
312 .any(|v| v.derivation != Derivation::ExactDerived)
313 || i.context.keys().any(|k| i.generated.contains_key(k))
314 {
315 return Err("invalid carried context".into());
316 }
317 for f in i.generated.values() {
318 slot(&f.origin, f.truncated, &f.source)?;
319 let valid = match &f.origin {
320 FieldOrigin::Absent { .. } => {
321 f.value.is_none() && f.derivation.is_none() && f.lineage.is_none()
322 }
323 FieldOrigin::Backstop { .. } => {
324 f.value.is_some()
325 && f.derivation == Some(Derivation::ExactDerived)
326 && matches!(&f.lineage, Some(TextLineage::Backstop { .. }))
327 }
328 FieldOrigin::Generated | FieldOrigin::Repaired => {
329 f.value.is_some()
330 && f.derivation == Some(Derivation::ModelDerived)
331 && matches!(&f.lineage, Some(TextLineage::Model { step, attempt, .. }) if step == &s.step && !attempt.is_empty())
332 }
333 };
334 if !valid {
335 return Err(
336 "field value, derivation or lineage disagrees with origin".into()
337 );
338 }
339 }
340 }
341 summary(
342 s.items
343 .iter()
344 .flat_map(|i| i.generated.values().map(|f| &f.origin)),
345 s.text_origin,
346 &s.counts,
347 )?;
348 }
349 for n in &self.notices {
350 if n.field.is_empty() {
351 return Err("text notice requires a field".into());
352 }
353 if n.kind == TextNoticeKind::OwnershipRestored
354 && n.step.is_empty()
355 && n.instance.is_empty()
356 && n.field == "judgment"
357 {
358 continue;
359 }
360 let s = self
361 .steps
362 .iter()
363 .find(|s| s.step == n.step)
364 .ok_or("text notice names unknown step")?;
365 let i = s
366 .items
367 .iter()
368 .find(|i| i.id == n.instance)
369 .ok_or("text notice names unknown item")?;
370 match n.kind {
371 TextNoticeKind::OwnershipViolation => {}
372 TextNoticeKind::OwnershipRestored => {
373 if !i.context.contains_key(&n.field) {
374 return Err("ownership restored notice requires context field".into());
375 }
376 }
377 TextNoticeKind::BackstopTruncated => {
378 if !matches!(
379 i.generated.get(&n.field).map(|f| &f.origin),
380 Some(FieldOrigin::Backstop { .. })
381 ) {
382 return Err("backstop truncated notice requires backstop field".into());
383 }
384 }
385 }
386 }
387 summary(
388 self.steps.iter().flat_map(|s| {
389 s.items
390 .iter()
391 .flat_map(|i| i.generated.values().map(|f| &f.origin))
392 }),
393 self.text_origin,
394 &self.counts,
395 )
396 }
397}
398
399pub(crate) fn evidence<'de, D: serde::Deserializer<'de>>(
400 d: D,
401) -> Result<Option<Vec<GenerationEvidence>>, D::Error> {
402 let v = Vec::<GenerationEvidence>::deserialize(d)?;
403 let check = || -> Result<(), String> {
404 if v.is_empty() || v.len() > 8 {
405 return Err("generation requires one to eight steps".into());
406 }
407 let mut steps = BTreeSet::new();
408 for s in &v {
409 if s.step.is_empty() || !steps.insert(&s.step) {
410 return Err("invalid or duplicate generate step".into());
411 }
412 if s.state != GenerationState::Ran && !s.items.is_empty() {
413 return Err("skipped step has items".into());
414 }
415 let mut items = BTreeSet::new();
416 for i in &s.items {
417 if !items.insert(&i.id) {
418 return Err("duplicate generation evidence item".into());
419 }
420 if i.context.len() > 16 || i.generated.is_empty() || i.generated.len() > 32 {
421 return Err("generation evidence field bounds".into());
422 }
423 names(i.generated.keys())?;
424 if i.context.keys().any(|k| i.generated.contains_key(k)) {
425 return Err("overlapping context and generated fields".into());
426 }
427 for f in i.generated.values() {
428 slot(&f.origin, f.truncated, &f.source)?;
429 let absent = matches!(f.origin, FieldOrigin::Absent { .. });
430 if f.byte_length.is_some() == absent || f.value_digest.is_some() == absent {
431 return Err("evidence value metadata disagrees with origin".into());
432 }
433 }
434 }
435 summary(
436 s.items
437 .iter()
438 .flat_map(|i| i.generated.values().map(|f| &f.origin)),
439 s.text_origin,
440 &s.counts,
441 )?;
442 }
443 Ok(())
444 };
445 check().map_err(serde::de::Error::custom)?;
446 Ok(Some(v))
447}
448
449pub fn generation_value_digest<T: serde::Serialize>(
452 value: &T,
453) -> Result<ContentDigest, crate::canonical::CanonicalError> {
454 use sha2::{Digest, Sha256};
455 let bytes = crate::canonical::canonical_bytes(value)?;
456 let hash = Sha256::digest(bytes);
457 Ok(ContentDigest::from_digest(format!("sha256:{hash:x}")))
458}