Skip to main content

harn_kernel/
predicate.rs

1//! Portable projection of checked predicate sites. The frontend owns site
2//! discovery and type admission; this layer adds stable content identities.
3
4use 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    /// Runtime-bound sites are declarations only; their actual questions and
18    /// route are bound by the execution receipt, not this source census.
19    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
20    pub runtime_admission: bool,
21    /// One digest over the whole question set. This is the cache identity
22    /// component a site's questions contribute; a reordered, renamed, or
23    /// relabelled question set is a different evaluation.
24    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/// A question census entry. Instructions are hashed rather than carried: a
34/// manifest travels further than the source it describes.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct PredicateManifestQuestion {
37    pub id: String,
38    pub kind: String,
39    pub instructions_sha256: String,
40    /// Criteria labels for a choice, levels for a score, zero for a boolean.
41    pub option_count: usize,
42}
43
44/// A length-delimited, domain-separated encoding. Concatenating the parts
45/// directly would let a renamed label and a lengthened id collide.
46fn 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    /// Called only after source analysis completes. A failed parse is not an
68    /// empty manifest: the containing check report must keep it absent.
69    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        // `ab` + `c` and `a` + `bc` are the same bytes concatenated. A
199        // length-delimited encoding keeps them distinct, so a relabelled
200        // question cannot reuse another question set's cache identity.
201        assert_ne!(
202            question_set_digest(&[choice("q", &["ab", "c"])]),
203            question_set_digest(&[choice("q", &["a", "bc"])]),
204        );
205        // Label order is significant: a choice's probabilities are keyed by
206        // label and a score's levels are ordered.
207        assert_ne!(
208            question_set_digest(&[choice("q", &["a", "b"])]),
209            question_set_digest(&[choice("q", &["b", "a"])]),
210        );
211        // A known non-null read through the same path, so the inequalities
212        // above cannot be two empty digests comparing equal to nothing.
213        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}