Skip to main content

perspt_sdk/prompt/
section.rs

1//! Prompt sections: identity, schema, and deterministic rendering
2//! (PSP-10 system 23, Definition 4).
3//!
4//! A section is a small, single-concern unit with typed front matter. Its
5//! body is prose plus `{{variable}}` placeholders and nothing else — no
6//! conditionals, loops, includes, or filters. `perspt-prompt-macros`
7//! validates the files at build time and generates one typed struct per
8//! section; [`SectionTemplate::render`] here is the single implementation
9//! of the substitution rules both the generated code and the external
10//! bundle scanner use.
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::canon::CanonicalEncoder;
17use crate::error::{Result, SdkError};
18
19use super::vars::{ListStyle, VarValue};
20
21/// Domain tag for every prompt-plane digest.
22pub const PROMPT_DIGEST_TAG: &[u8] = b"perspt-prompt-v1";
23
24/// Section identity, `"stage/section"`.
25#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26pub struct PromptSectionId(pub String);
27
28impl std::fmt::Display for PromptSectionId {
29    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        formatter.write_str(&self.0)
31    }
32}
33
34/// Monotone section version.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub struct PromptSectionVersion(pub u32);
37
38/// The message slot a section renders into.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum PromptMessageRole {
42    System,
43    User,
44}
45
46/// One declared variable in a section's front matter.
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct VarSpec {
49    /// The `{{name}}` the body uses.
50    pub name: String,
51    /// The declared Rust type, verbatim from the front matter (e.g.
52    /// `"BoundedList<64,128>"`). Codegen maps it to a supported type; the
53    /// runtime treats it as opaque schema identity.
54    pub declared_type: String,
55    /// Declared list style, when the type is a list.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub style: Option<ListStyle>,
58}
59
60/// A compiled section's schema: what a replacement body must satisfy
61/// (system 25 — external bundles may replace a known section only under
62/// exactly this schema).
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct SectionSchema {
65    pub id: PromptSectionId,
66    pub version: PromptSectionVersion,
67    pub role: PromptMessageRole,
68    pub required: bool,
69    /// Higher survives budget fitting longer; unused for required sections.
70    pub priority: u16,
71    pub max_bytes: usize,
72    pub vars: Vec<VarSpec>,
73}
74
75/// A section template: schema plus the reviewed body text.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct SectionTemplate {
78    pub schema: SectionSchema,
79    /// The body with `{{placeholders}}`; content-addressed.
80    pub body: String,
81    /// SHA-256 of the body bytes, `sha256:`-prefixed.
82    pub content_hash: String,
83}
84
85impl SectionTemplate {
86    /// Content hash of a section body under the prompt digest discipline.
87    pub fn hash_body(body: &str) -> String {
88        let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
89        encoder.text("section-body").text(body);
90        encoder.digest()
91    }
92
93    /// Deterministic rendering: substitute every placeholder, dropping the
94    /// whole line when an optional or list value is absent/empty. Fails
95    /// closed on an undeclared placeholder, a missing value, or a rendered
96    /// body over `max_bytes`.
97    pub fn render(&self, values: &BTreeMap<String, VarValue>) -> Result<RenderedSection> {
98        let mut lines = Vec::new();
99        for line in self.body.lines() {
100            match render_line(line, &self.schema, values)? {
101                Some(rendered) => lines.push(rendered),
102                None => continue,
103            }
104        }
105        let content = lines.join("\n");
106        if content.len() > self.schema.max_bytes {
107            return Err(SdkError::Domain(format!(
108                "section {} rendered to {} bytes over its {}-byte cap",
109                self.schema.id,
110                content.len(),
111                self.schema.max_bytes
112            )));
113        }
114        Ok(RenderedSection {
115            id: self.schema.id.clone(),
116            version: self.schema.version,
117            role: self.schema.role,
118            required: self.schema.required,
119            priority: self.schema.priority,
120            content_hash: self.content_hash.clone(),
121            content,
122        })
123    }
124}
125
126/// Substitute one line, or drop it (`None`) when an omitting value appears.
127fn render_line(
128    line: &str,
129    schema: &SectionSchema,
130    values: &BTreeMap<String, VarValue>,
131) -> Result<Option<String>> {
132    let mut rendered = String::new();
133    let mut rest = line;
134    while let Some(open) = rest.find("{{") {
135        let Some(close) = rest[open..].find("}}") else {
136            return Err(SdkError::Domain(format!(
137                "unterminated placeholder in line {line:?}"
138            )));
139        };
140        let name = rest[open + 2..open + close].trim();
141        let spec = schema
142            .vars
143            .iter()
144            .find(|spec| spec.name == name)
145            .ok_or_else(|| SdkError::Domain(format!("undeclared placeholder {{{{{name}}}}}")))?;
146        let value = values
147            .get(name)
148            .ok_or_else(|| SdkError::Domain(format!("no value supplied for {{{{{name}}}}}")))?;
149        if value.omits_line() {
150            return Ok(None);
151        }
152        rendered.push_str(&rest[..open]);
153        rendered.push_str(&value.render(spec.style)?);
154        rest = &rest[open + close + 2..];
155    }
156    rendered.push_str(rest);
157    Ok(Some(rendered))
158}
159
160/// A rendered section carrying its provenance-relevant identity.
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct RenderedSection {
163    pub id: PromptSectionId,
164    pub version: PromptSectionVersion,
165    pub role: PromptMessageRole,
166    pub required: bool,
167    pub priority: u16,
168    /// Hash of the template body this rendering came from.
169    pub content_hash: String,
170    pub content: String,
171}
172
173/// Where a resolved section came from (Definition 4's override chain).
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum OverrideOrigin {
177    Base,
178    Family(String),
179    ExactModel(String),
180}
181
182/// Ledger-facing provenance of one composed section.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct SectionProvenance {
185    pub id: PromptSectionId,
186    pub version: PromptSectionVersion,
187    pub content_hash: String,
188    pub origin: OverrideOrigin,
189}
190
191/// The compile-time contract every generated section struct satisfies
192/// (system 23's generated API).
193pub trait PromptSection {
194    const ID: &'static str;
195    const VERSION: u32;
196    const CONTENT_HASH: &'static str;
197    const REQUIRED: bool;
198    const PRIORITY: u16;
199
200    /// Deterministic rendering of this section with its typed values.
201    fn render(&self) -> Result<RenderedSection>;
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn template() -> SectionTemplate {
209        let body = "Use only these tools:\n{{tool_names}}\n{{budget_note}}\nEnd.".to_string();
210        SectionTemplate {
211            schema: SectionSchema {
212                id: PromptSectionId("branch_correct/tool_protocol".into()),
213                version: PromptSectionVersion(3),
214                role: PromptMessageRole::System,
215                required: true,
216                priority: 90,
217                max_bytes: 4096,
218                vars: vec![
219                    VarSpec {
220                        name: "tool_names".into(),
221                        declared_type: "BoundedList<64,128>".into(),
222                        style: Some(ListStyle::BulletList),
223                    },
224                    VarSpec {
225                        name: "budget_note".into(),
226                        declared_type: "Option<BoundedText<512>>".into(),
227                        style: None,
228                    },
229                ],
230            },
231            content_hash: SectionTemplate::hash_body(body.as_str()),
232            body,
233        }
234    }
235
236    #[test]
237    fn substitution_and_line_omission_are_deterministic() {
238        let template = template();
239        let mut values = BTreeMap::new();
240        values.insert(
241            "tool_names".to_string(),
242            VarValue::List(vec!["read_file".into(), "edit_file".into()]),
243        );
244        values.insert("budget_note".to_string(), VarValue::Absent);
245        let rendered = template.render(&values).unwrap();
246        assert_eq!(
247            rendered.content,
248            "Use only these tools:\n- read_file\n- edit_file\nEnd."
249        );
250        assert_eq!(rendered.content, template.render(&values).unwrap().content);
251    }
252
253    #[test]
254    fn undeclared_and_missing_placeholders_fail_closed() {
255        let mut template = template();
256        template.body = "{{ghost}}".into();
257        assert!(template.render(&BTreeMap::new()).is_err());
258        let template = self::tests::template();
259        assert!(template.render(&BTreeMap::new()).is_err());
260    }
261
262    #[test]
263    fn oversize_rendering_fails_closed() {
264        let mut template = template();
265        template.schema.max_bytes = 8;
266        let mut values = BTreeMap::new();
267        values.insert(
268            "tool_names".to_string(),
269            VarValue::List(vec!["read_file".into()]),
270        );
271        values.insert("budget_note".to_string(), VarValue::Absent);
272        assert!(template.render(&values).is_err());
273    }
274}