use std::collections::BTreeMap;
use crate::budget::{PROMPT_SEPARATOR, effective_remaining_bytes, estimate_tokens, truncate};
use crate::{
BudgetBehavior, ConflictKey, ConflictMode, ContextError, ContextErrorCode, PromptBudget,
PromptDiagnostic, PromptDiagnosticCode, PromptInspectionEntry, PromptModule, PromptModuleId,
PromptSegment, PromptSegmentId, SegmentDisposition,
};
pub const MAX_COMPILE_MODULES: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompiledPrompt {
text: String,
estimated_tokens: usize,
diagnostics: Vec<PromptDiagnostic>,
inspection: Vec<PromptInspectionEntry>,
}
impl CompiledPrompt {
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn bytes(&self) -> usize {
self.text.len()
}
#[must_use]
pub const fn estimated_tokens(&self) -> usize {
self.estimated_tokens
}
#[must_use]
pub fn diagnostics(&self) -> &[PromptDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn inspection(&self) -> &[PromptInspectionEntry] {
&self.inspection
}
}
#[derive(Clone)]
struct Candidate {
module_id: PromptModuleId,
authority: crate::PromptAuthority,
priority: crate::PromptPriority,
segment_order: usize,
segment: PromptSegment,
}
impl Candidate {
fn same_precedence(&self, other: &Self) -> bool {
self.authority == other.authority && self.priority == other.priority
}
}
type Selection = (
Vec<Candidate>,
Vec<PromptDiagnostic>,
Vec<PromptInspectionEntry>,
);
#[derive(Debug, Clone, Copy, Default)]
pub struct PromptCompiler;
impl PromptCompiler {
pub fn compile(
&self,
modules: impl IntoIterator<Item = PromptModule>,
budget: PromptBudget,
) -> Result<CompiledPrompt, ContextError> {
let modules = modules.into_iter().collect::<Vec<_>>();
if modules.len() > MAX_COMPILE_MODULES {
return Err(ContextError::new(
ContextErrorCode::BoundsExceeded,
"prompt compilation contains too many modules",
));
}
let mut candidates = flatten(modules);
candidates.sort_by(|left, right| {
left.authority
.cmp(&right.authority)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| left.module_id.cmp(&right.module_id))
.then_with(|| left.segment_order.cmp(&right.segment_order))
});
let (selected, mut diagnostics, mut inspection) = select(candidates)?;
render(selected, budget, &mut diagnostics, &mut inspection)
}
}
fn flatten(modules: Vec<PromptModule>) -> Vec<Candidate> {
modules
.into_iter()
.flat_map(|module| {
let module_id = module.id().clone();
let authority = module.authority();
let priority = module.priority();
let segments = module.segments().to_vec();
segments
.into_iter()
.enumerate()
.map(move |(segment_order, segment)| Candidate {
module_id: module_id.clone(),
authority,
priority,
segment_order,
segment,
})
})
.collect()
}
fn select(candidates: Vec<Candidate>) -> Result<Selection, ContextError> {
let mut identities: BTreeMap<PromptSegmentId, Candidate> = BTreeMap::new();
let mut conflicts: BTreeMap<ConflictKey, Candidate> = BTreeMap::new();
let mut selected = Vec::new();
let mut diagnostics = Vec::new();
let mut inspection = Vec::new();
for candidate in candidates {
if let Some(existing) = identities.get(candidate.segment.id()) {
if existing.segment != candidate.segment {
return Err(ContextError::new(
ContextErrorCode::DuplicateIdentity,
"prompt segment identity has divergent definitions",
));
}
diagnostics.push(PromptDiagnostic::new(
PromptDiagnosticCode::ExactDuplicate,
candidate.segment.id().clone(),
Some(existing.segment.id().clone()),
None,
));
inspection.push(nonrendered(&candidate, SegmentDisposition::Duplicate));
continue;
}
identities.insert(candidate.segment.id().clone(), candidate.clone());
if let Some(claim) = candidate.segment.conflict() {
if let Some(winner) = conflicts.get(claim.key()) {
if candidate.same_precedence(winner)
&& candidate.segment.content() != winner.segment.content()
{
return Err(ContextError::new(
ContextErrorCode::AmbiguousConflict,
"equal-precedence prompt conflict is ambiguous",
));
}
let code = if winner
.segment
.conflict()
.is_some_and(|value| value.mode() == ConflictMode::Protected)
{
PromptDiagnosticCode::ProtectedConflict
} else {
PromptDiagnosticCode::ConflictShadowed
};
diagnostics.push(PromptDiagnostic::new(
code,
candidate.segment.id().clone(),
Some(winner.segment.id().clone()),
Some(claim.key().clone()),
));
inspection.push(nonrendered(
&candidate,
SegmentDisposition::ConflictShadowed,
));
continue;
}
conflicts.insert(claim.key().clone(), candidate.clone());
}
selected.push(candidate);
}
Ok((selected, diagnostics, inspection))
}
fn render(
selected: Vec<Candidate>,
budget: PromptBudget,
diagnostics: &mut Vec<PromptDiagnostic>,
inspection: &mut Vec<PromptInspectionEntry>,
) -> Result<CompiledPrompt, ContextError> {
let mut text = String::new();
for candidate in selected {
let separator_bytes = if text.is_empty() {
0
} else {
PROMPT_SEPARATOR.len()
};
let used_with_separator = text.len().saturating_add(separator_bytes);
let remaining = effective_remaining_bytes(budget, used_with_separator);
let content = candidate.segment.content();
let (rendered, disposition) = if content.len() <= remaining {
(Some(content.to_owned()), SegmentDisposition::Included)
} else {
match candidate.segment.budget_behavior() {
BudgetBehavior::Required => {
return Err(ContextError::new(
ContextErrorCode::BudgetExceeded,
"required prompt segment exceeds compilation budget",
));
}
BudgetBehavior::Omit => (None, SegmentDisposition::OmittedForBudget),
BudgetBehavior::Truncate => truncate(content, remaining)
.map_or((None, SegmentDisposition::OmittedForBudget), |value| {
(Some(value), SegmentDisposition::Truncated)
}),
}
};
let Some(rendered) = rendered else {
diagnostics.push(PromptDiagnostic::new(
PromptDiagnosticCode::OmittedForBudget,
candidate.segment.id().clone(),
None,
None,
));
inspection.push(nonrendered(
&candidate,
SegmentDisposition::OmittedForBudget,
));
continue;
};
if !text.is_empty() {
text.push_str(PROMPT_SEPARATOR);
}
let start = text.len();
text.push_str(&rendered);
let end = text.len();
if disposition == SegmentDisposition::Truncated {
diagnostics.push(PromptDiagnostic::new(
PromptDiagnosticCode::TruncatedForBudget,
candidate.segment.id().clone(),
None,
None,
));
}
inspection.push(PromptInspectionEntry::new(
candidate.module_id,
candidate.segment.id().clone(),
candidate.segment.provenance().clone(),
candidate.segment.trust(),
candidate.segment.cache_scope(),
disposition,
Some(start..end),
rendered.len(),
estimate_tokens(rendered.len()),
));
}
inspection.sort_by(|left, right| {
left.byte_range()
.map_or(usize::MAX, |range| range.start)
.cmp(&right.byte_range().map_or(usize::MAX, |range| range.start))
.then_with(|| left.module_id().cmp(right.module_id()))
.then_with(|| left.segment_id().cmp(right.segment_id()))
});
diagnostics.sort_by(|left, right| {
left.segment_id()
.cmp(right.segment_id())
.then_with(|| left.code().cmp(&right.code()))
});
let estimated_tokens = estimate_tokens(text.len());
if text.len() > budget.max_bytes() || estimated_tokens > budget.max_estimated_tokens() {
return Err(ContextError::new(
ContextErrorCode::BudgetExceeded,
"compiled prompt is empty or exceeds final budget",
));
}
Ok(CompiledPrompt {
text,
estimated_tokens,
diagnostics: diagnostics.clone(),
inspection: inspection.clone(),
})
}
fn nonrendered(candidate: &Candidate, disposition: SegmentDisposition) -> PromptInspectionEntry {
PromptInspectionEntry::new(
candidate.module_id.clone(),
candidate.segment.id().clone(),
candidate.segment.provenance().clone(),
candidate.segment.trust(),
candidate.segment.cache_scope(),
disposition,
None,
0,
0,
)
}