1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use super::output_contract_validation::{
13 normalize_optional_schema, validate_text, MAX_FIELDS, MAX_FIELD_NAME_LEN,
14};
15use crate::error::DomainError;
16use crate::value_objects::{
17 EvidenceGroundingRule, OutputContractId, OutputFieldRule, OutputFormat,
18};
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct OutputContract {
23 contract_id: OutputContractId,
24 format: OutputFormat,
25 #[serde(default)]
26 fields: BTreeMap<String, OutputFieldRule>,
27 #[serde(default, skip_serializing_if = "String::is_empty")]
33 json_schema: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
38 evidence_grounding: Option<EvidenceGroundingRule>,
39}
40
41impl OutputContract {
42 pub fn new(
43 contract_id: impl Into<String>,
44 format: OutputFormat,
45 fields: BTreeMap<String, OutputFieldRule>,
46 ) -> Result<Self, DomainError> {
47 Self::new_with_schema(contract_id, format, fields, String::new())
48 }
49
50 pub fn new_with_schema(
57 contract_id: impl Into<String>,
58 format: OutputFormat,
59 fields: BTreeMap<String, OutputFieldRule>,
60 json_schema: impl Into<String>,
61 ) -> Result<Self, DomainError> {
62 let contract_id = OutputContractId::new(contract_id)?;
63 if fields.len() > MAX_FIELDS {
64 return Err(DomainError::OutOfRange {
65 field: "output_contract.fields",
66 value: fields.len() as f64,
67 min: 0.0,
68 max: MAX_FIELDS as f64,
69 });
70 }
71
72 let mut normalized = BTreeMap::new();
73 for (name, rule) in fields {
74 let field_name =
75 validate_text(&name, "output_contract.field.name", MAX_FIELD_NAME_LEN)?;
76 normalized.insert(field_name, rule);
77 }
78
79 let json_schema = normalize_optional_schema(&json_schema.into())?;
80
81 Ok(Self {
82 contract_id,
83 format,
84 fields: normalized,
85 json_schema,
86 evidence_grounding: None,
87 })
88 }
89
90 pub fn json_object(
91 contract_id: impl Into<String>,
92 fields: BTreeMap<String, OutputFieldRule>,
93 ) -> Result<Self, DomainError> {
94 Self::new(contract_id, OutputFormat::JsonObject, fields)
95 }
96
97 #[must_use]
98 pub const fn contract_id(&self) -> &OutputContractId {
99 &self.contract_id
100 }
101
102 #[must_use]
103 pub const fn format(&self) -> OutputFormat {
104 self.format
105 }
106
107 #[must_use]
108 pub fn fields(&self) -> &BTreeMap<String, OutputFieldRule> {
109 &self.fields
110 }
111
112 #[must_use]
116 pub fn json_schema(&self) -> &str {
117 &self.json_schema
118 }
119
120 #[must_use]
122 pub fn with_evidence_grounding(mut self, rule: EvidenceGroundingRule) -> Self {
123 self.evidence_grounding = Some(rule);
124 self
125 }
126
127 #[must_use]
130 pub fn evidence_grounding(&self) -> Option<&EvidenceGroundingRule> {
131 self.evidence_grounding.as_ref()
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::super::output_contract_validation::MAX_JSON_SCHEMA_LEN;
138 use super::*;
139 use crate::value_objects::SemanticSupportRule;
140
141 fn sample_rule() -> OutputFieldRule {
142 OutputFieldRule::new(true, ["emit_event", "escalate"]).unwrap()
143 }
144
145 #[test]
146 fn json_object_contract_keeps_fields() {
147 let contract = OutputContract::json_object(
148 "decision-contract",
149 BTreeMap::from([("decision".to_owned(), sample_rule())]),
150 )
151 .unwrap();
152
153 assert_eq!(contract.contract_id(), "decision-contract");
154 assert_eq!(contract.format(), OutputFormat::JsonObject);
155 assert!(contract.fields()["decision"].required());
156 assert!(contract.fields()["decision"]
157 .allowed_string_values()
158 .contains("emit_event"));
159 }
160
161 #[test]
162 fn blank_contract_id_is_rejected() {
163 let err = OutputContract::json_object(" ", BTreeMap::new()).unwrap_err();
164 assert!(matches!(
165 err,
166 DomainError::EmptyField {
167 field: "output_contract.contract_id"
168 }
169 ));
170 }
171
172 #[test]
173 fn blank_field_name_is_rejected() {
174 let err = OutputContract::json_object(
175 "c1",
176 BTreeMap::from([(" ".to_owned(), OutputFieldRule::default())]),
177 )
178 .unwrap_err();
179 assert!(matches!(
180 err,
181 DomainError::EmptyField {
182 field: "output_contract.field.name"
183 }
184 ));
185 }
186
187 #[test]
188 fn blank_allowed_value_is_rejected() {
189 let err = OutputFieldRule::new(false, [" "]).unwrap_err();
190 assert!(matches!(
191 err,
192 DomainError::EmptyField {
193 field: "output_contract.field.allowed_value"
194 }
195 ));
196 }
197
198 #[test]
199 fn serde_roundtrip_is_stable() {
200 let contract = OutputContract::json_object(
201 "decision-contract",
202 BTreeMap::from([("decision".to_owned(), sample_rule())]),
203 )
204 .unwrap();
205 let serialized = serde_json::to_string(&contract).unwrap();
206 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
207 assert_eq!(back, contract);
208 }
209
210 #[test]
211 fn json_schema_is_empty_by_default() {
212 let contract = OutputContract::json_object("c1", BTreeMap::new()).unwrap();
213 assert!(contract.json_schema().is_empty());
214 }
215
216 #[test]
217 fn new_with_schema_carries_trimmed_body() {
218 let raw = " { \"type\": \"object\" } ";
219 let contract = OutputContract::new_with_schema(
220 "decision-contract",
221 OutputFormat::JsonObject,
222 BTreeMap::new(),
223 raw,
224 )
225 .unwrap();
226 assert_eq!(contract.json_schema(), "{ \"type\": \"object\" }");
227 }
228
229 #[test]
230 fn overlong_schema_is_rejected() {
231 let body = "x".repeat(MAX_JSON_SCHEMA_LEN + 1);
232 let err =
233 OutputContract::new_with_schema("c1", OutputFormat::JsonObject, BTreeMap::new(), body)
234 .unwrap_err();
235 assert!(matches!(
236 err,
237 DomainError::FieldTooLong {
238 field: "output_contract.json_schema",
239 ..
240 }
241 ));
242 }
243
244 #[test]
245 fn evidence_grounding_rule_keeps_fields_and_refs() {
246 let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
247 assert_eq!(rule.claims_field(), "claims");
248 assert_eq!(rule.refs_field(), "evidence_refs");
249 assert!(rule.allowed_refs().contains("ev-1"));
250 assert_eq!(rule.allowed_refs().len(), 2);
251 }
252
253 #[test]
254 fn evidence_grounding_rule_rejects_empty_pack() {
255 let err = EvidenceGroundingRule::new("claims", "evidence_refs", Vec::<String>::new())
256 .unwrap_err();
257 assert!(matches!(
258 err,
259 DomainError::EmptyField {
260 field: "output_contract.evidence.allowed_refs"
261 }
262 ));
263 }
264
265 #[test]
266 fn evidence_grounding_rule_rejects_blank_ref() {
267 let err = EvidenceGroundingRule::new("claims", "evidence_refs", [" "]).unwrap_err();
268 assert!(matches!(
269 err,
270 DomainError::EmptyField {
271 field: "output_contract.evidence.allowed_ref"
272 }
273 ));
274 }
275
276 #[test]
277 fn contract_with_evidence_grounding_roundtrips() {
278 let contract = OutputContract::json_object("c1", BTreeMap::new())
279 .unwrap()
280 .with_evidence_grounding(
281 EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"]).unwrap(),
282 );
283 let serialized = serde_json::to_string(&contract).unwrap();
284 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
285 assert_eq!(back, contract);
286 assert_eq!(back.evidence_grounding().unwrap().claims_field(), "claims");
287 }
288
289 #[test]
290 fn semantic_support_rule_keeps_bodies_and_threshold() {
291 let rule =
292 SemanticSupportRule::new(80, [("ev-1", "typha held port 5473"), ("ev-2", "crun log")])
293 .unwrap();
294 assert_eq!(rule.min_confidence(), 80);
295 assert_eq!(rule.body("ev-1"), Some("typha held port 5473"));
296 assert_eq!(rule.bodies().len(), 2);
297 }
298
299 #[test]
300 fn semantic_support_rule_rejects_out_of_range_confidence() {
301 let err = SemanticSupportRule::new(101, [("ev-1", "body")]).unwrap_err();
302 assert!(matches!(
303 err,
304 DomainError::OutOfRange {
305 field: "output_contract.evidence.semantic_support.min_confidence",
306 ..
307 }
308 ));
309 }
310
311 #[test]
312 fn semantic_support_rule_rejects_empty_bodies() {
313 let err = SemanticSupportRule::new(70, Vec::<(String, String)>::new()).unwrap_err();
314 assert!(matches!(
315 err,
316 DomainError::EmptyField {
317 field: "output_contract.evidence.semantic_support.bodies"
318 }
319 ));
320 }
321
322 #[test]
323 fn semantic_support_rule_rejects_blank_body() {
324 let err = SemanticSupportRule::new(70, [("ev-1", " ")]).unwrap_err();
325 assert!(matches!(
326 err,
327 DomainError::EmptyField {
328 field: "output_contract.evidence.semantic_support.body"
329 }
330 ));
331 }
332
333 #[test]
334 fn semantic_support_requires_a_body_for_every_allowed_ref() {
335 let grounding =
336 EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
337 let partial = SemanticSupportRule::new(70, [("ev-1", "only one body")]).unwrap();
338 let err = grounding.with_semantic_support(partial).unwrap_err();
339 assert!(matches!(
340 err,
341 DomainError::EmptyField {
342 field: "output_contract.evidence.semantic_support.bodies"
343 }
344 ));
345 }
346
347 #[test]
348 fn grounding_with_semantic_support_roundtrips() {
349 let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"])
350 .unwrap()
351 .with_semantic_support(SemanticSupportRule::new(70, [("ev-1", "body")]).unwrap())
352 .unwrap();
353 let contract = OutputContract::json_object("c1", BTreeMap::new())
354 .unwrap()
355 .with_evidence_grounding(rule);
356 let serialized = serde_json::to_string(&contract).unwrap();
357 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
358 assert_eq!(back, contract);
359 let support = back
360 .evidence_grounding()
361 .unwrap()
362 .semantic_support()
363 .unwrap();
364 assert_eq!(support.min_confidence(), 70);
365 assert_eq!(support.body("ev-1"), Some("body"));
366 }
367
368 #[test]
369 fn grounding_without_semantic_support_deserializes_from_legacy_wire_shape() {
370 let legacy =
373 r#"{"claims_field":"claims","refs_field":"evidence_refs","allowed_refs":["ev-1"]}"#;
374 let back: EvidenceGroundingRule = serde_json::from_str(legacy).unwrap();
375 assert!(back.semantic_support().is_none());
376 }
377
378 #[test]
379 fn contract_without_grounding_deserializes_from_legacy_wire_shape() {
380 let legacy = r#"{"contract_id":"c1","format":"JsonObject","fields":{}}"#;
383 let back: OutputContract = serde_json::from_str(legacy).unwrap();
384 assert!(back.evidence_grounding().is_none());
385 }
386
387 #[test]
388 fn schema_serde_roundtrip_preserves_body() {
389 let contract = OutputContract::new_with_schema(
390 "c1",
391 OutputFormat::JsonObject,
392 BTreeMap::new(),
393 "{\"type\":\"object\"}",
394 )
395 .unwrap();
396 let serialized = serde_json::to_string(&contract).unwrap();
397 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
398 assert_eq!(back, contract);
399 assert_eq!(back.json_schema(), "{\"type\":\"object\"}");
400 }
401}