Skip to main content

made_core/value_objects/
rubric.rs

1//! [`Rubric`] value object.
2//!
3//! Opaque, structured guidance given to agents and validators during
4//! deliberation. MADE never interprets the contents of a
5//! rubric: callers attach their own domain vocabulary here, and agent
6//! / validator adapters read it.
7//!
8//! Using an opaque wrapper (rather than `serde_json::Value` directly)
9//! gives us a domain-level name, enforces non-primitive boundaries,
10//! and lets us evolve the internal representation without touching
11//! callers.
12
13use std::collections::BTreeMap;
14use std::fmt;
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::error::DomainError;
20
21/// Soft upper bound on the number of top-level keys. Prevents accidental
22/// transmission of very large, unstructured payloads through the domain.
23pub const MAX_RUBRIC_KEYS: usize = 256;
24
25/// A rubric is a map of string keys to structured JSON-shaped values.
26///
27/// The map is ordered ([`BTreeMap`]) to keep serialization stable and
28/// simplify equality for tests.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct Rubric(BTreeMap<String, Value>);
32
33impl Rubric {
34    pub fn new(entries: BTreeMap<String, Value>) -> Result<Self, DomainError> {
35        if entries.len() > MAX_RUBRIC_KEYS {
36            return Err(DomainError::OutOfRange {
37                field: "rubric.keys",
38                value: entries.len() as f64,
39                min: 0.0,
40                max: MAX_RUBRIC_KEYS as f64,
41            });
42        }
43        for key in entries.keys() {
44            if key.trim().is_empty() {
45                return Err(DomainError::EmptyField {
46                    field: "rubric.key",
47                });
48            }
49        }
50        Ok(Self(entries))
51    }
52
53    #[must_use]
54    pub fn empty() -> Self {
55        Self::default()
56    }
57
58    #[must_use]
59    pub fn len(&self) -> usize {
60        self.0.len()
61    }
62
63    #[must_use]
64    pub fn is_empty(&self) -> bool {
65        self.0.is_empty()
66    }
67
68    #[must_use]
69    pub fn get(&self, key: &str) -> Option<&Value> {
70        self.0.get(key)
71    }
72
73    #[must_use]
74    pub fn as_map(&self) -> &BTreeMap<String, Value> {
75        &self.0
76    }
77
78    #[must_use]
79    pub fn into_inner(self) -> BTreeMap<String, Value> {
80        self.0
81    }
82}
83
84impl fmt::Display for Rubric {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "Rubric({} keys)", self.0.len())
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94
95    fn entry(key: &str, value: Value) -> BTreeMap<String, Value> {
96        let mut m = BTreeMap::new();
97        m.insert(key.to_owned(), value);
98        m
99    }
100
101    #[test]
102    fn empty_rubric_is_valid() {
103        let r = Rubric::empty();
104        assert!(r.is_empty());
105        assert_eq!(r.len(), 0);
106    }
107
108    #[test]
109    fn arbitrary_keys_and_values_are_accepted() {
110        let r = Rubric::new(entry("criteria", json!({"rigor": "high"}))).unwrap();
111        assert_eq!(r.get("criteria"), Some(&json!({"rigor": "high"})));
112    }
113
114    #[test]
115    fn blank_key_is_rejected() {
116        let err = Rubric::new(entry("  ", json!(null))).unwrap_err();
117        assert!(matches!(
118            err,
119            DomainError::EmptyField {
120                field: "rubric.key"
121            }
122        ));
123    }
124
125    #[test]
126    fn too_many_keys_is_rejected() {
127        let mut m = BTreeMap::new();
128        for i in 0..=MAX_RUBRIC_KEYS {
129            m.insert(format!("k{i}"), json!(i));
130        }
131        assert!(matches!(
132            Rubric::new(m).unwrap_err(),
133            DomainError::OutOfRange {
134                field: "rubric.keys",
135                ..
136            }
137        ));
138    }
139
140    #[test]
141    fn domain_neutrality_is_preserved() {
142        // The rubric layer must accept any shape from any domain.
143        let cases = [
144            ("software", json!({"quality": "high"})),
145            ("clinical", json!({"capa_required": true})),
146            ("logistics", json!({"sla_minutes": 120})),
147        ];
148        for (key, value) in cases {
149            Rubric::new(entry(key, value)).unwrap();
150        }
151    }
152
153    #[test]
154    fn serde_is_transparent_map() {
155        let r = Rubric::new(entry("k", json!(1))).unwrap();
156        let s = serde_json::to_string(&r).unwrap();
157        assert_eq!(s, r#"{"k":1}"#);
158        let back: Rubric = serde_json::from_str(&s).unwrap();
159        assert_eq!(back, r);
160    }
161
162    #[test]
163    fn display_shows_count() {
164        let r = Rubric::new(entry("k", json!(1))).unwrap();
165        assert_eq!(r.to_string(), "Rubric(1 keys)");
166    }
167}