Skip to main content

perspt_sdk/prompt/
dialect.rs

1//! Model dialects (PSP-10 system 24).
2//!
3//! Structural differences between model families never fork prose: system
4//! slot layout, tool-call convention, and reasoning-trace handling live in
5//! one typed value per resolved route. The dialect id and version enter the
6//! program digest.
7
8use serde::{Deserialize, Serialize};
9
10use super::accountant::TokenAccountantRef;
11
12/// How compiled system sections are laid out at the transport boundary.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SystemSlotPolicy {
16    /// The transport accepts multiple system messages.
17    Many,
18    /// One system slot; sections concatenate with the boundary marker.
19    SingleConcatenated,
20}
21
22/// How the model emits tool calls.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ToolCallConvention {
26    /// Provider-native structured tool calls.
27    Native,
28    /// JSON tool-call envelopes inside plain text.
29    JsonInText,
30}
31
32/// How reasoning traces are requested or removed before parsing.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum ReasoningTracePolicy {
36    None,
37    Requested,
38    Stripped,
39}
40
41/// A dialect reference as recorded in compiled programs and the ledger.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct DialectRef {
44    pub id: String,
45    pub version: u32,
46}
47
48/// The typed structural profile of one resolved model route.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct ModelDialect {
51    pub id: String,
52    pub version: u32,
53    pub system_slots: SystemSlotPolicy,
54    pub tool_calls: ToolCallConvention,
55    pub reasoning: ReasoningTracePolicy,
56    /// Delimiter between concatenated system sections.
57    pub boundary_marker: String,
58    pub context_window_tokens: u64,
59    pub max_output_tokens: u64,
60    pub token_accountant: TokenAccountantRef,
61    /// Independent transport byte bound; not derived from tokens.
62    pub max_prompt_bytes: u64,
63}
64
65impl ModelDialect {
66    pub fn dialect_ref(&self) -> DialectRef {
67        DialectRef {
68            id: self.id.clone(),
69            version: self.version,
70        }
71    }
72
73    /// A family with native tool calls and multiple system slots.
74    pub fn native_tools_v1() -> Self {
75        Self {
76            id: "native_tools_v1".into(),
77            version: 1,
78            system_slots: SystemSlotPolicy::Many,
79            tool_calls: ToolCallConvention::Native,
80            reasoning: ReasoningTracePolicy::Stripped,
81            boundary_marker: String::new(),
82            context_window_tokens: 131_072,
83            max_output_tokens: 16_384,
84            token_accountant: TokenAccountantRef::approx_bytes_v1(),
85            max_prompt_bytes: 262_144,
86        }
87    }
88
89    /// The live genai adapter: native tool calls behind one system slot.
90    /// The compiled system sections join with the stage's declared
91    /// separator (see `StageComposition::system_separator`), which keeps
92    /// the migrated platform literals byte-identical on the wire.
93    pub fn genai_single_slot_v1() -> Self {
94        Self {
95            id: "genai_single_slot_v1".into(),
96            version: 1,
97            system_slots: SystemSlotPolicy::SingleConcatenated,
98            tool_calls: ToolCallConvention::Native,
99            reasoning: ReasoningTracePolicy::Stripped,
100            boundary_marker: String::new(),
101            context_window_tokens: 131_072,
102            max_output_tokens: 16_384,
103            token_accountant: TokenAccountantRef::approx_bytes_v1(),
104            max_prompt_bytes: 262_144,
105        }
106    }
107
108    /// The same dialect fitted to one route's declared context window. The
109    /// effective token budget is identity-bearing and enters the program
110    /// digest, so narrowing it is recorded, never silent.
111    pub fn with_context_window(mut self, tokens: u64) -> Self {
112        self.context_window_tokens = tokens.max(1);
113        self
114    }
115
116    /// A family reached through a single-system-slot endpoint that emits
117    /// tool calls as JSON in text.
118    pub fn json_single_slot_v1() -> Self {
119        Self {
120            id: "json_single_slot_v1".into(),
121            version: 1,
122            system_slots: SystemSlotPolicy::SingleConcatenated,
123            tool_calls: ToolCallConvention::JsonInText,
124            reasoning: ReasoningTracePolicy::None,
125            boundary_marker: "\n===== perspt:boundary =====\n".into(),
126            context_window_tokens: 65_536,
127            max_output_tokens: 8_192,
128            token_accountant: TokenAccountantRef::approx_bytes_v1(),
129            max_prompt_bytes: 131_072,
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn the_two_contrasting_dialects_differ_structurally() {
140        let native = ModelDialect::native_tools_v1();
141        let json = ModelDialect::json_single_slot_v1();
142        assert_ne!(native.system_slots, json.system_slots);
143        assert_ne!(native.tool_calls, json.tool_calls);
144        assert!(json.boundary_marker.contains("perspt:boundary"));
145        assert_eq!(native.dialect_ref().id, "native_tools_v1");
146    }
147}