1use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14const MAX_CONTRACT_ID_LEN: usize = 128;
15const MAX_FIELDS: usize = 128;
16const MAX_FIELD_NAME_LEN: usize = 128;
17const MAX_ALLOWED_VALUES_PER_FIELD: usize = 128;
18const MAX_ALLOWED_VALUE_LEN: usize = 256;
19const MAX_JSON_SCHEMA_LEN: usize = 256 * 1024;
24const MAX_ALLOWED_EVIDENCE_REFS: usize = 1024;
29const MAX_EVIDENCE_BODY_LEN: usize = 16 * 1024;
34pub const DEFAULT_SUPPORT_MIN_CONFIDENCE: u8 = 70;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40pub enum OutputFormat {
41 #[default]
43 JsonObject,
44}
45
46impl OutputFormat {
47 #[must_use]
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::JsonObject => "json_object",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
57pub struct OutputFieldRule {
58 required: bool,
59 #[serde(default)]
60 allowed_string_values: BTreeSet<String>,
61}
62
63impl OutputFieldRule {
64 pub fn new(
65 required: bool,
66 allowed_string_values: impl IntoIterator<Item = impl Into<String>>,
67 ) -> Result<Self, DomainError> {
68 let values = allowed_string_values
69 .into_iter()
70 .map(|value| {
71 let value = value.into();
72 validate_text(
73 &value,
74 "output_contract.field.allowed_value",
75 MAX_ALLOWED_VALUE_LEN,
76 )
77 })
78 .collect::<Result<BTreeSet<_>, _>>()?;
79 if values.len() > MAX_ALLOWED_VALUES_PER_FIELD {
80 return Err(DomainError::OutOfRange {
81 field: "output_contract.field.allowed_values",
82 value: values.len() as f64,
83 min: 0.0,
84 max: MAX_ALLOWED_VALUES_PER_FIELD as f64,
85 });
86 }
87 Ok(Self {
88 required,
89 allowed_string_values: values,
90 })
91 }
92
93 #[must_use]
94 pub const fn required(&self) -> bool {
95 self.required
96 }
97
98 #[must_use]
99 pub fn allowed_string_values(&self) -> &BTreeSet<String> {
100 &self.allowed_string_values
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct SemanticSupportRule {
117 min_confidence: u8,
118 bodies: BTreeMap<String, String>,
119}
120
121impl SemanticSupportRule {
122 pub fn new(
127 min_confidence: u8,
128 bodies: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
129 ) -> Result<Self, DomainError> {
130 if min_confidence > 100 {
131 return Err(DomainError::OutOfRange {
132 field: "output_contract.evidence.semantic_support.min_confidence",
133 value: f64::from(min_confidence),
134 min: 0.0,
135 max: 100.0,
136 });
137 }
138 let bodies = bodies
139 .into_iter()
140 .map(|(reference, body)| {
141 let reference = validate_text(
142 &reference.into(),
143 "output_contract.evidence.semantic_support.body_ref",
144 MAX_ALLOWED_VALUE_LEN,
145 )?;
146 let body = validate_text(
147 &body.into(),
148 "output_contract.evidence.semantic_support.body",
149 MAX_EVIDENCE_BODY_LEN,
150 )?;
151 Ok::<_, DomainError>((reference, body))
152 })
153 .collect::<Result<BTreeMap<_, _>, _>>()?;
154 if bodies.is_empty() {
155 return Err(DomainError::EmptyField {
156 field: "output_contract.evidence.semantic_support.bodies",
157 });
158 }
159 if bodies.len() > MAX_ALLOWED_EVIDENCE_REFS {
160 return Err(DomainError::OutOfRange {
161 field: "output_contract.evidence.semantic_support.bodies",
162 value: bodies.len() as f64,
163 min: 1.0,
164 max: MAX_ALLOWED_EVIDENCE_REFS as f64,
165 });
166 }
167 Ok(Self {
168 min_confidence,
169 bodies,
170 })
171 }
172
173 #[must_use]
176 pub const fn min_confidence(&self) -> u8 {
177 self.min_confidence
178 }
179
180 #[must_use]
182 pub fn bodies(&self) -> &BTreeMap<String, String> {
183 &self.bodies
184 }
185
186 #[must_use]
188 pub fn body(&self, reference: &str) -> Option<&str> {
189 self.bodies.get(reference).map(String::as_str)
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct EvidenceGroundingRule {
206 claims_field: String,
207 refs_field: String,
208 allowed_refs: BTreeSet<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
213 semantic_support: Option<SemanticSupportRule>,
214}
215
216impl EvidenceGroundingRule {
217 pub fn new(
221 claims_field: impl Into<String>,
222 refs_field: impl Into<String>,
223 allowed_refs: impl IntoIterator<Item = impl Into<String>>,
224 ) -> Result<Self, DomainError> {
225 let claims_field = validate_text(
226 &claims_field.into(),
227 "output_contract.evidence.claims_field",
228 MAX_FIELD_NAME_LEN,
229 )?;
230 let refs_field = validate_text(
231 &refs_field.into(),
232 "output_contract.evidence.refs_field",
233 MAX_FIELD_NAME_LEN,
234 )?;
235 let allowed_refs = allowed_refs
236 .into_iter()
237 .map(|reference| {
238 let reference = reference.into();
239 validate_text(
240 &reference,
241 "output_contract.evidence.allowed_ref",
242 MAX_ALLOWED_VALUE_LEN,
243 )
244 })
245 .collect::<Result<BTreeSet<_>, _>>()?;
246 if allowed_refs.is_empty() {
247 return Err(DomainError::EmptyField {
248 field: "output_contract.evidence.allowed_refs",
249 });
250 }
251 if allowed_refs.len() > MAX_ALLOWED_EVIDENCE_REFS {
252 return Err(DomainError::OutOfRange {
253 field: "output_contract.evidence.allowed_refs",
254 value: allowed_refs.len() as f64,
255 min: 1.0,
256 max: MAX_ALLOWED_EVIDENCE_REFS as f64,
257 });
258 }
259 Ok(Self {
260 claims_field,
261 refs_field,
262 allowed_refs,
263 semantic_support: None,
264 })
265 }
266
267 pub fn with_semantic_support(mut self, rule: SemanticSupportRule) -> Result<Self, DomainError> {
273 if self
274 .allowed_refs
275 .iter()
276 .any(|reference| !rule.bodies.contains_key(reference))
277 {
278 return Err(DomainError::EmptyField {
279 field: "output_contract.evidence.semantic_support.bodies",
280 });
281 }
282 self.semantic_support = Some(rule);
283 Ok(self)
284 }
285
286 #[must_use]
287 pub fn claims_field(&self) -> &str {
288 &self.claims_field
289 }
290
291 #[must_use]
292 pub fn refs_field(&self) -> &str {
293 &self.refs_field
294 }
295
296 #[must_use]
297 pub fn allowed_refs(&self) -> &BTreeSet<String> {
298 &self.allowed_refs
299 }
300
301 #[must_use]
304 pub fn semantic_support(&self) -> Option<&SemanticSupportRule> {
305 self.semantic_support.as_ref()
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311pub struct OutputContract {
312 contract_id: String,
313 format: OutputFormat,
314 #[serde(default)]
315 fields: BTreeMap<String, OutputFieldRule>,
316 #[serde(default, skip_serializing_if = "String::is_empty")]
322 json_schema: String,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
327 evidence_grounding: Option<EvidenceGroundingRule>,
328}
329
330impl OutputContract {
331 pub fn new(
332 contract_id: impl Into<String>,
333 format: OutputFormat,
334 fields: BTreeMap<String, OutputFieldRule>,
335 ) -> Result<Self, DomainError> {
336 Self::new_with_schema(contract_id, format, fields, String::new())
337 }
338
339 pub fn new_with_schema(
346 contract_id: impl Into<String>,
347 format: OutputFormat,
348 fields: BTreeMap<String, OutputFieldRule>,
349 json_schema: impl Into<String>,
350 ) -> Result<Self, DomainError> {
351 let contract_id = contract_id.into();
352 let contract_id = validate_text(
353 &contract_id,
354 "output_contract.contract_id",
355 MAX_CONTRACT_ID_LEN,
356 )?;
357 if fields.len() > MAX_FIELDS {
358 return Err(DomainError::OutOfRange {
359 field: "output_contract.fields",
360 value: fields.len() as f64,
361 min: 0.0,
362 max: MAX_FIELDS as f64,
363 });
364 }
365
366 let mut normalized = BTreeMap::new();
367 for (name, rule) in fields {
368 let field_name =
369 validate_text(&name, "output_contract.field.name", MAX_FIELD_NAME_LEN)?;
370 normalized.insert(field_name, rule);
371 }
372
373 let json_schema = normalize_optional_schema(&json_schema.into())?;
374
375 Ok(Self {
376 contract_id,
377 format,
378 fields: normalized,
379 json_schema,
380 evidence_grounding: None,
381 })
382 }
383
384 pub fn json_object(
385 contract_id: impl Into<String>,
386 fields: BTreeMap<String, OutputFieldRule>,
387 ) -> Result<Self, DomainError> {
388 Self::new(contract_id, OutputFormat::JsonObject, fields)
389 }
390
391 #[must_use]
392 pub fn contract_id(&self) -> &str {
393 &self.contract_id
394 }
395
396 #[must_use]
397 pub const fn format(&self) -> OutputFormat {
398 self.format
399 }
400
401 #[must_use]
402 pub fn fields(&self) -> &BTreeMap<String, OutputFieldRule> {
403 &self.fields
404 }
405
406 #[must_use]
410 pub fn json_schema(&self) -> &str {
411 &self.json_schema
412 }
413
414 #[must_use]
416 pub fn with_evidence_grounding(mut self, rule: EvidenceGroundingRule) -> Self {
417 self.evidence_grounding = Some(rule);
418 self
419 }
420
421 #[must_use]
424 pub fn evidence_grounding(&self) -> Option<&EvidenceGroundingRule> {
425 self.evidence_grounding.as_ref()
426 }
427}
428
429fn normalize_optional_schema(raw: &str) -> Result<String, DomainError> {
430 let trimmed = raw.trim();
431 if trimmed.is_empty() {
432 return Ok(String::new());
433 }
434 if trimmed.len() > MAX_JSON_SCHEMA_LEN {
435 return Err(DomainError::FieldTooLong {
436 field: "output_contract.json_schema",
437 actual: trimmed.len(),
438 max: MAX_JSON_SCHEMA_LEN,
439 });
440 }
441 Ok(trimmed.to_owned())
442}
443
444fn validate_text(value: &str, field: &'static str, max_len: usize) -> Result<String, DomainError> {
445 let trimmed = value.trim();
446 if trimmed.is_empty() {
447 return Err(DomainError::EmptyField { field });
448 }
449 if trimmed.len() > max_len {
450 return Err(DomainError::FieldTooLong {
451 field,
452 actual: trimmed.len(),
453 max: max_len,
454 });
455 }
456 Ok(trimmed.to_owned())
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn sample_rule() -> OutputFieldRule {
464 OutputFieldRule::new(true, ["emit_event", "escalate"]).unwrap()
465 }
466
467 #[test]
468 fn json_object_contract_keeps_fields() {
469 let contract = OutputContract::json_object(
470 "decision-contract",
471 BTreeMap::from([("decision".to_owned(), sample_rule())]),
472 )
473 .unwrap();
474
475 assert_eq!(contract.contract_id(), "decision-contract");
476 assert_eq!(contract.format(), OutputFormat::JsonObject);
477 assert!(contract.fields()["decision"].required());
478 assert!(contract.fields()["decision"]
479 .allowed_string_values()
480 .contains("emit_event"));
481 }
482
483 #[test]
484 fn blank_contract_id_is_rejected() {
485 let err = OutputContract::json_object(" ", BTreeMap::new()).unwrap_err();
486 assert!(matches!(
487 err,
488 DomainError::EmptyField {
489 field: "output_contract.contract_id"
490 }
491 ));
492 }
493
494 #[test]
495 fn blank_field_name_is_rejected() {
496 let err = OutputContract::json_object(
497 "c1",
498 BTreeMap::from([(" ".to_owned(), OutputFieldRule::default())]),
499 )
500 .unwrap_err();
501 assert!(matches!(
502 err,
503 DomainError::EmptyField {
504 field: "output_contract.field.name"
505 }
506 ));
507 }
508
509 #[test]
510 fn blank_allowed_value_is_rejected() {
511 let err = OutputFieldRule::new(false, [" "]).unwrap_err();
512 assert!(matches!(
513 err,
514 DomainError::EmptyField {
515 field: "output_contract.field.allowed_value"
516 }
517 ));
518 }
519
520 #[test]
521 fn serde_roundtrip_is_stable() {
522 let contract = OutputContract::json_object(
523 "decision-contract",
524 BTreeMap::from([("decision".to_owned(), sample_rule())]),
525 )
526 .unwrap();
527 let serialized = serde_json::to_string(&contract).unwrap();
528 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
529 assert_eq!(back, contract);
530 }
531
532 #[test]
533 fn json_schema_is_empty_by_default() {
534 let contract = OutputContract::json_object("c1", BTreeMap::new()).unwrap();
535 assert!(contract.json_schema().is_empty());
536 }
537
538 #[test]
539 fn new_with_schema_carries_trimmed_body() {
540 let raw = " { \"type\": \"object\" } ";
541 let contract = OutputContract::new_with_schema(
542 "decision-contract",
543 OutputFormat::JsonObject,
544 BTreeMap::new(),
545 raw,
546 )
547 .unwrap();
548 assert_eq!(contract.json_schema(), "{ \"type\": \"object\" }");
549 }
550
551 #[test]
552 fn overlong_schema_is_rejected() {
553 let body = "x".repeat(MAX_JSON_SCHEMA_LEN + 1);
554 let err =
555 OutputContract::new_with_schema("c1", OutputFormat::JsonObject, BTreeMap::new(), body)
556 .unwrap_err();
557 assert!(matches!(
558 err,
559 DomainError::FieldTooLong {
560 field: "output_contract.json_schema",
561 ..
562 }
563 ));
564 }
565
566 #[test]
567 fn evidence_grounding_rule_keeps_fields_and_refs() {
568 let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
569 assert_eq!(rule.claims_field(), "claims");
570 assert_eq!(rule.refs_field(), "evidence_refs");
571 assert!(rule.allowed_refs().contains("ev-1"));
572 assert_eq!(rule.allowed_refs().len(), 2);
573 }
574
575 #[test]
576 fn evidence_grounding_rule_rejects_empty_pack() {
577 let err = EvidenceGroundingRule::new("claims", "evidence_refs", Vec::<String>::new())
578 .unwrap_err();
579 assert!(matches!(
580 err,
581 DomainError::EmptyField {
582 field: "output_contract.evidence.allowed_refs"
583 }
584 ));
585 }
586
587 #[test]
588 fn evidence_grounding_rule_rejects_blank_ref() {
589 let err = EvidenceGroundingRule::new("claims", "evidence_refs", [" "]).unwrap_err();
590 assert!(matches!(
591 err,
592 DomainError::EmptyField {
593 field: "output_contract.evidence.allowed_ref"
594 }
595 ));
596 }
597
598 #[test]
599 fn contract_with_evidence_grounding_roundtrips() {
600 let contract = OutputContract::json_object("c1", BTreeMap::new())
601 .unwrap()
602 .with_evidence_grounding(
603 EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"]).unwrap(),
604 );
605 let serialized = serde_json::to_string(&contract).unwrap();
606 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
607 assert_eq!(back, contract);
608 assert_eq!(back.evidence_grounding().unwrap().claims_field(), "claims");
609 }
610
611 #[test]
612 fn semantic_support_rule_keeps_bodies_and_threshold() {
613 let rule =
614 SemanticSupportRule::new(80, [("ev-1", "typha held port 5473"), ("ev-2", "crun log")])
615 .unwrap();
616 assert_eq!(rule.min_confidence(), 80);
617 assert_eq!(rule.body("ev-1"), Some("typha held port 5473"));
618 assert_eq!(rule.bodies().len(), 2);
619 }
620
621 #[test]
622 fn semantic_support_rule_rejects_out_of_range_confidence() {
623 let err = SemanticSupportRule::new(101, [("ev-1", "body")]).unwrap_err();
624 assert!(matches!(
625 err,
626 DomainError::OutOfRange {
627 field: "output_contract.evidence.semantic_support.min_confidence",
628 ..
629 }
630 ));
631 }
632
633 #[test]
634 fn semantic_support_rule_rejects_empty_bodies() {
635 let err = SemanticSupportRule::new(70, Vec::<(String, String)>::new()).unwrap_err();
636 assert!(matches!(
637 err,
638 DomainError::EmptyField {
639 field: "output_contract.evidence.semantic_support.bodies"
640 }
641 ));
642 }
643
644 #[test]
645 fn semantic_support_rule_rejects_blank_body() {
646 let err = SemanticSupportRule::new(70, [("ev-1", " ")]).unwrap_err();
647 assert!(matches!(
648 err,
649 DomainError::EmptyField {
650 field: "output_contract.evidence.semantic_support.body"
651 }
652 ));
653 }
654
655 #[test]
656 fn semantic_support_requires_a_body_for_every_allowed_ref() {
657 let grounding =
658 EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1", "ev-2"]).unwrap();
659 let partial = SemanticSupportRule::new(70, [("ev-1", "only one body")]).unwrap();
660 let err = grounding.with_semantic_support(partial).unwrap_err();
661 assert!(matches!(
662 err,
663 DomainError::EmptyField {
664 field: "output_contract.evidence.semantic_support.bodies"
665 }
666 ));
667 }
668
669 #[test]
670 fn grounding_with_semantic_support_roundtrips() {
671 let rule = EvidenceGroundingRule::new("claims", "evidence_refs", ["ev-1"])
672 .unwrap()
673 .with_semantic_support(SemanticSupportRule::new(70, [("ev-1", "body")]).unwrap())
674 .unwrap();
675 let contract = OutputContract::json_object("c1", BTreeMap::new())
676 .unwrap()
677 .with_evidence_grounding(rule);
678 let serialized = serde_json::to_string(&contract).unwrap();
679 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
680 assert_eq!(back, contract);
681 let support = back
682 .evidence_grounding()
683 .unwrap()
684 .semantic_support()
685 .unwrap();
686 assert_eq!(support.min_confidence(), 70);
687 assert_eq!(support.body("ev-1"), Some("body"));
688 }
689
690 #[test]
691 fn grounding_without_semantic_support_deserializes_from_legacy_wire_shape() {
692 let legacy =
695 r#"{"claims_field":"claims","refs_field":"evidence_refs","allowed_refs":["ev-1"]}"#;
696 let back: EvidenceGroundingRule = serde_json::from_str(legacy).unwrap();
697 assert!(back.semantic_support().is_none());
698 }
699
700 #[test]
701 fn contract_without_grounding_deserializes_from_legacy_wire_shape() {
702 let legacy = r#"{"contract_id":"c1","format":"JsonObject","fields":{}}"#;
705 let back: OutputContract = serde_json::from_str(legacy).unwrap();
706 assert!(back.evidence_grounding().is_none());
707 }
708
709 #[test]
710 fn schema_serde_roundtrip_preserves_body() {
711 let contract = OutputContract::new_with_schema(
712 "c1",
713 OutputFormat::JsonObject,
714 BTreeMap::new(),
715 "{\"type\":\"object\"}",
716 )
717 .unwrap();
718 let serialized = serde_json::to_string(&contract).unwrap();
719 let back: OutputContract = serde_json::from_str(&serialized).unwrap();
720 assert_eq!(back, contract);
721 assert_eq!(back.json_schema(), "{\"type\":\"object\"}");
722 }
723}