Skip to main content

perspt_sdk/prompt/
digest.rs

1//! Compiled prompt programs and their digests (PSP-10 system 23).
2//!
3//! Digests use the length-prefixed canonical encoding under the
4//! `perspt-prompt-v1` domain tag. Serde output is never treated as
5//! canonical. Identical inputs — dialect id and version, ordered resolved
6//! sections with ids, versions, and hashes, inclusion outcomes, canonical
7//! variable values (already baked into rendered bytes), token budget —
8//! yield identical rendered bytes, an identical dropped-section set, and
9//! identical digests.
10
11use serde::{Deserialize, Serialize};
12
13use crate::canon::CanonicalEncoder;
14
15use super::dialect::DialectRef;
16use super::route::PromptRoute;
17use super::section::{PromptMessageRole, PromptSectionId, SectionProvenance, PROMPT_DIGEST_TAG};
18use super::stage::PromptStage;
19
20/// One transport-facing message compiled from sections.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct CompiledPromptMessage {
23    pub role: PromptMessageRole,
24    pub content: String,
25    /// The sections this message renders, in order.
26    pub sections: Vec<SectionProvenance>,
27    /// SHA-256 of the rendered content under the prompt digest tag.
28    pub rendered_hash: String,
29}
30
31impl CompiledPromptMessage {
32    pub fn hash_content(content: &str) -> String {
33        let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
34        encoder.text("rendered-message").text(content);
35        encoder.digest()
36    }
37}
38
39/// One compiled program: the platform envelope or the domain stage.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct CompiledPromptProgram {
42    pub route: PromptRoute,
43    pub stage: PromptStage,
44    pub dialect: DialectRef,
45    pub messages: Vec<CompiledPromptMessage>,
46    /// Sections dropped by deterministic budget fitting, in drop order.
47    pub dropped_sections: Vec<PromptSectionId>,
48    pub tool_spec_hash: String,
49    pub program_digest: String,
50}
51
52impl CompiledPromptProgram {
53    /// The program digest over canonical bytes of the identity-bearing
54    /// fields. Rendered content participates through each message's
55    /// rendered hash.
56    #[allow(clippy::too_many_arguments)]
57    pub fn compute_digest(
58        route: &PromptRoute,
59        stage: PromptStage,
60        dialect: &DialectRef,
61        accountant_id: &str,
62        token_budget: u64,
63        messages: &[CompiledPromptMessage],
64        dropped: &[PromptSectionId],
65        tool_spec_hash: &str,
66    ) -> String {
67        let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
68        encoder
69            .text("program")
70            .text(&route.adapter)
71            .text(&format!("{:?}", route.family))
72            .text(route.exact_model.as_deref().unwrap_or(""))
73            .text(stage.dir_name())
74            .text(&dialect.id)
75            .u64(u64::from(dialect.version))
76            .text(accountant_id)
77            .u64(token_budget);
78        for message in messages {
79            encoder.text(match message.role {
80                PromptMessageRole::System => "system",
81                PromptMessageRole::User => "user",
82            });
83            encoder.text(&message.rendered_hash);
84            for section in &message.sections {
85                encoder
86                    .text(&section.id.0)
87                    .u64(u64::from(section.version.0))
88                    .text(&section.content_hash)
89                    .text(&format!("{:?}", section.origin));
90            }
91        }
92        encoder.list(dropped.iter().map(|id| id.0.as_str()));
93        encoder.text(tool_spec_hash);
94        encoder.digest()
95    }
96}
97
98impl CompiledPromptProgram {
99    /// The transport-facing system text: system-role messages in layout
100    /// order. Under a single-slot dialect this is exactly the one
101    /// concatenated slot.
102    pub fn system_text(&self) -> String {
103        self.messages
104            .iter()
105            .filter(|message| message.role == PromptMessageRole::System)
106            .map(|message| message.content.as_str())
107            .collect::<Vec<_>>()
108            .join("\n\n")
109    }
110}
111
112/// The content hash of the exact tool surface offered on one model call:
113/// name, description, and schema of every spec, in offered order. Enters
114/// the compiled program digest, so a discovery-activated tool visibly
115/// changes the program identity.
116pub fn tool_surface_hash(specs: &[crate::model::ToolSpec]) -> String {
117    let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
118    encoder.text("tool-surface");
119    for spec in specs {
120        encoder
121            .text(&spec.name)
122            .text(&spec.description)
123            .text(&spec.schema.to_string())
124            .bool(spec.strict);
125    }
126    encoder.digest()
127}
128
129/// The two programs of one model call, hashed together in order.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131pub struct CompiledPromptInvocation {
132    pub platform: CompiledPromptProgram,
133    pub domain: CompiledPromptProgram,
134    pub invocation_digest: String,
135}
136
137impl CompiledPromptInvocation {
138    pub fn new(platform: CompiledPromptProgram, domain: CompiledPromptProgram) -> Self {
139        let mut encoder = CanonicalEncoder::new(PROMPT_DIGEST_TAG);
140        encoder
141            .text("invocation")
142            .text(&platform.program_digest)
143            .text(&domain.program_digest);
144        let invocation_digest = encoder.digest();
145        Self {
146            platform,
147            domain,
148            invocation_digest,
149        }
150    }
151}