1use std::collections::BTreeMap;
2
3use crate::budget::{PROMPT_SEPARATOR, effective_remaining_bytes, estimate_tokens, truncate};
4use crate::{
5 BudgetBehavior, ConflictKey, ConflictMode, ContextError, ContextErrorCode, PromptBudget,
6 PromptDiagnostic, PromptDiagnosticCode, PromptInspectionEntry, PromptModule, PromptModuleId,
7 PromptSegment, PromptSegmentId, SegmentDisposition,
8};
9
10pub const MAX_COMPILE_MODULES: usize = 1024;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CompiledPrompt {
16 text: String,
17 estimated_tokens: usize,
18 diagnostics: Vec<PromptDiagnostic>,
19 inspection: Vec<PromptInspectionEntry>,
20}
21
22impl CompiledPrompt {
23 #[must_use]
25 pub fn text(&self) -> &str {
26 &self.text
27 }
28 #[must_use]
30 pub fn bytes(&self) -> usize {
31 self.text.len()
32 }
33 #[must_use]
35 pub const fn estimated_tokens(&self) -> usize {
36 self.estimated_tokens
37 }
38 #[must_use]
40 pub fn diagnostics(&self) -> &[PromptDiagnostic] {
41 &self.diagnostics
42 }
43 #[must_use]
45 pub fn inspection(&self) -> &[PromptInspectionEntry] {
46 &self.inspection
47 }
48}
49
50#[derive(Clone)]
51struct Candidate {
52 module_id: PromptModuleId,
53 authority: crate::PromptAuthority,
54 priority: crate::PromptPriority,
55 segment_order: usize,
56 segment: PromptSegment,
57}
58
59impl Candidate {
60 fn same_precedence(&self, other: &Self) -> bool {
61 self.authority == other.authority && self.priority == other.priority
62 }
63}
64
65type Selection = (
66 Vec<Candidate>,
67 Vec<PromptDiagnostic>,
68 Vec<PromptInspectionEntry>,
69);
70
71#[derive(Debug, Clone, Copy, Default)]
73pub struct PromptCompiler;
74
75impl PromptCompiler {
76 pub fn compile(
83 &self,
84 modules: impl IntoIterator<Item = PromptModule>,
85 budget: PromptBudget,
86 ) -> Result<CompiledPrompt, ContextError> {
87 let modules = modules.into_iter().collect::<Vec<_>>();
88 if modules.len() > MAX_COMPILE_MODULES {
89 return Err(ContextError::new(
90 ContextErrorCode::BoundsExceeded,
91 "prompt compilation contains too many modules",
92 ));
93 }
94 let mut candidates = flatten(modules);
95 candidates.sort_by(|left, right| {
96 left.authority
97 .cmp(&right.authority)
98 .then_with(|| right.priority.cmp(&left.priority))
99 .then_with(|| left.module_id.cmp(&right.module_id))
100 .then_with(|| left.segment_order.cmp(&right.segment_order))
101 });
102 let (selected, mut diagnostics, mut inspection) = select(candidates)?;
103 render(selected, budget, &mut diagnostics, &mut inspection)
104 }
105}
106
107fn flatten(modules: Vec<PromptModule>) -> Vec<Candidate> {
108 modules
109 .into_iter()
110 .flat_map(|module| {
111 let module_id = module.id().clone();
112 let authority = module.authority();
113 let priority = module.priority();
114 let segments = module.segments().to_vec();
115 segments
116 .into_iter()
117 .enumerate()
118 .map(move |(segment_order, segment)| Candidate {
119 module_id: module_id.clone(),
120 authority,
121 priority,
122 segment_order,
123 segment,
124 })
125 })
126 .collect()
127}
128
129fn select(candidates: Vec<Candidate>) -> Result<Selection, ContextError> {
130 let mut identities: BTreeMap<PromptSegmentId, Candidate> = BTreeMap::new();
131 let mut conflicts: BTreeMap<ConflictKey, Candidate> = BTreeMap::new();
132 let mut selected = Vec::new();
133 let mut diagnostics = Vec::new();
134 let mut inspection = Vec::new();
135 for candidate in candidates {
136 if let Some(existing) = identities.get(candidate.segment.id()) {
137 if existing.segment != candidate.segment {
138 return Err(ContextError::new(
139 ContextErrorCode::DuplicateIdentity,
140 "prompt segment identity has divergent definitions",
141 ));
142 }
143 diagnostics.push(PromptDiagnostic::new(
144 PromptDiagnosticCode::ExactDuplicate,
145 candidate.segment.id().clone(),
146 Some(existing.segment.id().clone()),
147 None,
148 ));
149 inspection.push(nonrendered(&candidate, SegmentDisposition::Duplicate));
150 continue;
151 }
152 identities.insert(candidate.segment.id().clone(), candidate.clone());
153 if let Some(claim) = candidate.segment.conflict() {
154 if let Some(winner) = conflicts.get(claim.key()) {
155 if candidate.same_precedence(winner)
156 && candidate.segment.content() != winner.segment.content()
157 {
158 return Err(ContextError::new(
159 ContextErrorCode::AmbiguousConflict,
160 "equal-precedence prompt conflict is ambiguous",
161 ));
162 }
163 let code = if winner
164 .segment
165 .conflict()
166 .is_some_and(|value| value.mode() == ConflictMode::Protected)
167 {
168 PromptDiagnosticCode::ProtectedConflict
169 } else {
170 PromptDiagnosticCode::ConflictShadowed
171 };
172 diagnostics.push(PromptDiagnostic::new(
173 code,
174 candidate.segment.id().clone(),
175 Some(winner.segment.id().clone()),
176 Some(claim.key().clone()),
177 ));
178 inspection.push(nonrendered(
179 &candidate,
180 SegmentDisposition::ConflictShadowed,
181 ));
182 continue;
183 }
184 conflicts.insert(claim.key().clone(), candidate.clone());
185 }
186 selected.push(candidate);
187 }
188 Ok((selected, diagnostics, inspection))
189}
190
191fn render(
192 selected: Vec<Candidate>,
193 budget: PromptBudget,
194 diagnostics: &mut Vec<PromptDiagnostic>,
195 inspection: &mut Vec<PromptInspectionEntry>,
196) -> Result<CompiledPrompt, ContextError> {
197 let mut text = String::new();
198 for candidate in selected {
199 let separator_bytes = if text.is_empty() {
200 0
201 } else {
202 PROMPT_SEPARATOR.len()
203 };
204 let used_with_separator = text.len().saturating_add(separator_bytes);
205 let remaining = effective_remaining_bytes(budget, used_with_separator);
206 let content = candidate.segment.content();
207 let (rendered, disposition) = if content.len() <= remaining {
208 (Some(content.to_owned()), SegmentDisposition::Included)
209 } else {
210 match candidate.segment.budget_behavior() {
211 BudgetBehavior::Required => {
212 return Err(ContextError::new(
213 ContextErrorCode::BudgetExceeded,
214 "required prompt segment exceeds compilation budget",
215 ));
216 }
217 BudgetBehavior::Omit => (None, SegmentDisposition::OmittedForBudget),
218 BudgetBehavior::Truncate => truncate(content, remaining)
219 .map_or((None, SegmentDisposition::OmittedForBudget), |value| {
220 (Some(value), SegmentDisposition::Truncated)
221 }),
222 }
223 };
224 let Some(rendered) = rendered else {
225 diagnostics.push(PromptDiagnostic::new(
226 PromptDiagnosticCode::OmittedForBudget,
227 candidate.segment.id().clone(),
228 None,
229 None,
230 ));
231 inspection.push(nonrendered(
232 &candidate,
233 SegmentDisposition::OmittedForBudget,
234 ));
235 continue;
236 };
237 if !text.is_empty() {
238 text.push_str(PROMPT_SEPARATOR);
239 }
240 let start = text.len();
241 text.push_str(&rendered);
242 let end = text.len();
243 if disposition == SegmentDisposition::Truncated {
244 diagnostics.push(PromptDiagnostic::new(
245 PromptDiagnosticCode::TruncatedForBudget,
246 candidate.segment.id().clone(),
247 None,
248 None,
249 ));
250 }
251 inspection.push(PromptInspectionEntry::new(
252 candidate.module_id,
253 candidate.segment.id().clone(),
254 candidate.segment.provenance().clone(),
255 candidate.segment.trust(),
256 candidate.segment.cache_scope(),
257 disposition,
258 Some(start..end),
259 rendered.len(),
260 estimate_tokens(rendered.len()),
261 ));
262 }
263 inspection.sort_by(|left, right| {
264 left.byte_range()
265 .map_or(usize::MAX, |range| range.start)
266 .cmp(&right.byte_range().map_or(usize::MAX, |range| range.start))
267 .then_with(|| left.module_id().cmp(right.module_id()))
268 .then_with(|| left.segment_id().cmp(right.segment_id()))
269 });
270 diagnostics.sort_by(|left, right| {
271 left.segment_id()
272 .cmp(right.segment_id())
273 .then_with(|| left.code().cmp(&right.code()))
274 });
275 let estimated_tokens = estimate_tokens(text.len());
276 if text.len() > budget.max_bytes() || estimated_tokens > budget.max_estimated_tokens() {
277 return Err(ContextError::new(
278 ContextErrorCode::BudgetExceeded,
279 "compiled prompt is empty or exceeds final budget",
280 ));
281 }
282 Ok(CompiledPrompt {
283 text,
284 estimated_tokens,
285 diagnostics: diagnostics.clone(),
286 inspection: inspection.clone(),
287 })
288}
289
290fn nonrendered(candidate: &Candidate, disposition: SegmentDisposition) -> PromptInspectionEntry {
291 PromptInspectionEntry::new(
292 candidate.module_id.clone(),
293 candidate.segment.id().clone(),
294 candidate.segment.provenance().clone(),
295 candidate.segment.trust(),
296 candidate.segment.cache_scope(),
297 disposition,
298 None,
299 0,
300 0,
301 )
302}