1use harn_parser::{canonical_predicate_type, PredicateQuestionSpec, PredicateSite};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct PredicateManifest {
9 pub schema: String,
10 pub sites: Vec<PredicateManifestSite>,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct PredicateManifestSite {
15 pub source: String,
16 pub id: String,
17 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
20 pub runtime_admission: bool,
21 pub question_set_sha256: Option<String>,
25 pub questions: Vec<PredicateManifestQuestion>,
26 pub input_type_sha256: String,
27 pub outcome_schema: String,
28 pub effects: Vec<String>,
29 pub line: usize,
30 pub column: usize,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct PredicateManifestQuestion {
37 pub id: String,
38 pub kind: String,
39 pub instructions_sha256: String,
40 pub option_count: usize,
42}
43
44fn question_set_digest(questions: &[PredicateQuestionSpec]) -> String {
47 let mut encoded = Vec::new();
48 for question in questions {
49 for part in [
50 question.id.as_str(),
51 question.kind.as_str(),
52 question.instructions.as_str(),
53 ] {
54 encoded.extend_from_slice(&(part.len() as u64).to_le_bytes());
55 encoded.extend_from_slice(part.as_bytes());
56 }
57 encoded.extend_from_slice(&(question.labels.len() as u64).to_le_bytes());
58 for label in &question.labels {
59 encoded.extend_from_slice(&(label.len() as u64).to_le_bytes());
60 encoded.extend_from_slice(label.as_bytes());
61 }
62 }
63 crate::pure::sha256_hex(&encoded)
64}
65
66impl PredicateManifest {
67 pub fn from_checked_sites(source: &str, sites: &[PredicateSite]) -> Self {
70 Self {
71 schema: "harn.predicate_sites.v2".into(),
72 sites: sites
73 .iter()
74 .map(|site| PredicateManifestSite {
75 source: source.into(),
76 id: site.id.clone(),
77 runtime_admission: site.kind
78 == harn_parser::PredicateSiteKind::RuntimeEvaluation,
79 question_set_sha256: if site.kind
80 == harn_parser::PredicateSiteKind::RuntimeEvaluation
81 {
82 None
83 } else {
84 Some(question_set_digest(&site.questions))
85 },
86 questions: site
87 .questions
88 .iter()
89 .map(|question| PredicateManifestQuestion {
90 id: question.id.clone(),
91 kind: question.kind.as_str().into(),
92 instructions_sha256: crate::pure::sha256_hex(
93 question.instructions.as_bytes(),
94 ),
95 option_count: question.labels.len(),
96 })
97 .collect(),
98 input_type_sha256: crate::pure::sha256_hex(
99 canonical_predicate_type(&site.input_type).as_bytes(),
100 ),
101 outcome_schema: site.kind.outcome_schema().into(),
102 effects: vec!["llm.write".into()],
103 line: site.line,
104 column: site.column,
105 })
106 .collect(),
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use harn_parser::{PredicateQuestionKind, PredicateSiteKind, ShapeField, TypeExpr};
115
116 fn site(fields: Vec<ShapeField>) -> PredicateSite {
117 PredicateSite {
118 model_route: None,
119 id: "finding.v1".into(),
120 kind: PredicateSiteKind::Predicate,
121 questions: vec![PredicateQuestionSpec {
122 id: "finding.v1".into(),
123 kind: PredicateQuestionKind::Boolean,
124 instructions: "Supported?".into(),
125 labels: Vec::new(),
126 }],
127 input_type: TypeExpr::Shape(fields),
128 line: 7,
129 column: 3,
130 start: 40,
131 end: 90,
132 }
133 }
134
135 #[test]
136 fn manifest_hashes_semantics_and_retains_site_location() {
137 let a = ShapeField::synthetic("a", TypeExpr::Named("string".into()), false);
138 let b = ShapeField::synthetic("b", TypeExpr::Named("int".into()), false);
139 let first =
140 PredicateManifest::from_checked_sites("main.harn", &[site(vec![a.clone(), b.clone()])]);
141 let reordered = PredicateManifest::from_checked_sites("main.harn", &[site(vec![b, a])]);
142 assert_eq!(first, reordered);
143 assert_eq!(first.sites.len(), 1);
144 assert_eq!(first.sites[0].line, 7);
145 assert_eq!(first.sites[0].questions.len(), 1);
146 assert_eq!(first.sites[0].questions[0].kind, "boolean");
147 assert_eq!(first.sites[0].questions[0].option_count, 0);
148 assert_eq!(
149 first.sites[0].questions[0].instructions_sha256,
150 crate::pure::sha256_hex(b"Supported?")
151 );
152 let changed = PredicateManifest::from_checked_sites(
153 "main.harn",
154 &[site(vec![ShapeField::synthetic(
155 "a",
156 TypeExpr::Named("bool".into()),
157 false,
158 )])],
159 );
160 assert_ne!(
161 first.sites[0].input_type_sha256,
162 changed.sites[0].input_type_sha256
163 );
164 let bytes = serde_json::to_vec(&first).unwrap();
165 assert_eq!(
166 serde_json::from_slice::<PredicateManifest>(&bytes).unwrap(),
167 first
168 );
169 }
170
171 #[test]
172 fn runtime_manifest_does_not_claim_an_empty_question_set_was_bound() {
173 let mut runtime = site(vec![ShapeField::synthetic(
174 "text",
175 TypeExpr::Named("string".into()),
176 false,
177 )]);
178 runtime.kind = PredicateSiteKind::RuntimeEvaluation;
179 runtime.questions.clear();
180 let manifest = PredicateManifest::from_checked_sites("runtime.harn", &[runtime]);
181 assert!(manifest.sites[0].runtime_admission);
182 assert_eq!(manifest.sites[0].question_set_sha256, None);
183 let json = serde_json::to_value(manifest).unwrap();
184 assert!(json["sites"][0]["question_set_sha256"].is_null());
185 }
186
187 fn choice(id: &str, labels: &[&str]) -> PredicateQuestionSpec {
188 PredicateQuestionSpec {
189 id: id.into(),
190 kind: PredicateQuestionKind::Choice,
191 instructions: "Which one?".into(),
192 labels: labels.iter().map(|label| (*label).to_string()).collect(),
193 }
194 }
195
196 #[test]
197 fn question_set_digest_separates_parts_that_plain_concatenation_would_collide() {
198 assert_ne!(
202 question_set_digest(&[choice("q", &["ab", "c"])]),
203 question_set_digest(&[choice("q", &["a", "bc"])]),
204 );
205 assert_ne!(
208 question_set_digest(&[choice("q", &["a", "b"])]),
209 question_set_digest(&[choice("q", &["b", "a"])]),
210 );
211 assert_eq!(
214 question_set_digest(&[choice("q", &["a", "b"])]),
215 question_set_digest(&[choice("q", &["a", "b"])]),
216 );
217 assert_ne!(
218 question_set_digest(&[]),
219 question_set_digest(&[choice("q", &["a"])])
220 );
221 }
222
223 #[test]
224 fn manifest_effect_projection_matches_the_registered_contract() {
225 use harn_builtin_meta::{EffectAccess, EffectKind};
226 let entry = harn_capability_contracts::manifest()
227 .iter()
228 .find(|entry| entry.name == harn_builtin_meta::predicate::EVALUATE.name)
229 .expect("predicate contract is registered");
230 assert!(
231 !entry.contract.effects.is_empty(),
232 "absence is not a read-only evaluation"
233 );
234 assert!(entry
235 .contract
236 .effects
237 .iter()
238 .all(|effect| effect.kind == EffectKind::Llm && effect.access == EffectAccess::Write));
239 let manifest = PredicateManifest::from_checked_sites("main.harn", &[site(vec![])]);
240 assert_eq!(manifest.sites[0].effects, ["llm.write"]);
241 }
242}