use crate::context::{Additivity, ContextLoader};
use crate::error::CompileError;
use crate::model::{
AssetDecl, ComponentFile, Guardrail, Outcome, Param, Precondition, ProfileComponentMount,
ProfileFile, RenderBlock, SlotContents, SlotDecl, WhenGuard,
};
use std::collections::{HashMap, HashSet};
const PRECONDITION_VOCABULARY: &[&str] = &[
"mdl_parseable",
"has_metric",
"has_queryable_dimension",
"has_time_dimension",
"has_groupable_dimension",
"metric_additive",
"model_has_timestamp",
"lineage_resolvable",
"wren_project_exists",
"source_introspectable",
"raw_docs_readable",
];
const GUARD_VOCABULARY: &[&str] = &["on_failure", "on_flag", "on_missing"];
pub fn compile(
profile: &ProfileFile,
components: &HashMap<String, ComponentFile>,
project_as_authored: &str,
context: &dyn ContextLoader,
step_contents: &HashMap<String, HashMap<String, String>>,
slot_contents: &SlotContents,
) -> Result<serde_json::Value, CompileError> {
if !context.is_parseable() {
let detail = context
.parse_error()
.map(|e| format!(" — {e}"))
.unwrap_or_default();
return Err(CompileError(format!(
"context precondition failed: bound project '{project_as_authored}' is not a parseable \
wren project (mdl_parseable / wren_project_exists){detail}"
)));
}
check_profile_slots(profile, components, &slot_contents.profile)?;
let mut component_nodes = Vec::with_capacity(profile.components.len());
let mut first_binding_mode: Option<String> = None;
for mount in &profile.components {
let component = components.get(&mount.use_id).ok_or_else(|| {
CompileError(format!(
"component '{}' referenced by profile is not mounted",
mount.use_id
))
})?;
if first_binding_mode.is_none() {
first_binding_mode = Some(component.binding_mode.clone());
}
check_precondition_vocabulary(component)?;
check_capability_ceiling(component, profile)?;
check_param_sources(component)?;
check_selector_fields(component)?;
check_required_binds(component, mount)?;
check_when_guards(component)?;
let binds = resolve_binds(component, mount);
let (precondition_checks, resolved_precondition_args) =
evaluate_preconditions(component, context, &binds)?;
let guardrails = resolve_guardrails(component, mount)?;
check_step_dataflow(component)?;
check_step_capabilities(component)?;
let component_slot_contents = slot_contents.components.get(&component.id);
check_component_slots(
component,
mount,
step_contents.get(&component.id),
component_slot_contents,
)?;
let empty_steps: HashMap<String, String> = HashMap::new();
let steps_for_component = step_contents.get(&component.id).unwrap_or(&empty_steps);
let llm_calls =
resolve_llm_calls(component, mount, project_as_authored, steps_for_component)?;
let realization_kind = mount
.realization_kind
.clone()
.unwrap_or_else(|| component.realization_kind.clone());
let prompt_fragment =
render_prompt_fragment(component, project_as_authored, steps_for_component)?;
let context_binding = serde_json::json!({
"project": project_as_authored,
"binding_mode": component.binding_mode,
});
let mut node = serde_json::json!({
"id": component.id,
"verb": component.verb,
"type": component.component_type,
"realization_kind": realization_kind,
"context_binding": context_binding,
"context_requirements": component.context_requirements,
"context_precondition": precondition_json(&component.context_precondition, &resolved_precondition_args),
"params": params_json(&component.params),
"precondition_result": {
"status": "pass",
"checks": precondition_checks,
},
"prompt_fragment": prompt_fragment,
"llm_calls": llm_calls,
"guardrails": guardrails,
"trigger": { "kind": component.trigger.kind },
"required_capabilities": component.required_capabilities,
"borrowed_actions": component.borrowed_actions,
"eval_ref": format!("{}.eval", component.id),
"effect": {
"render_blocks": render_blocks_json(&component.effect.render_blocks),
"outcome": outcome_json(&component.effect.outcome),
},
});
if let Some(eval) = &component.eval {
node["eval"] = serde_json::json!({
"template_ref": eval.template_ref,
"metrics": eval.metrics,
});
}
if !binds.is_empty() {
node["binds"] = serde_json::json!(binds);
}
if let Some(brief) = render_brief(
component,
mount,
profile.system_prompt.as_deref(),
project_as_authored,
)? {
node["brief"] = serde_json::json!(brief);
}
if !component.slots.is_empty() {
node["slots"] = render_slots(
&component.slots,
component_slot_contents,
project_as_authored,
)?;
}
if !component.assets.is_empty() {
node["assets"] = render_assets(&component.assets)?;
}
if let Some(description) = component.description.as_deref().map(str::trim) {
if !description.is_empty() {
node["description"] = serde_json::json!(description);
}
}
let examples = component
.examples
.iter()
.map(|example| example.trim())
.filter(|example| !example.is_empty())
.collect::<Vec<_>>();
if !examples.is_empty() {
node["examples"] = serde_json::json!(examples);
}
component_nodes.push(node);
}
let top_binding_mode = first_binding_mode.unwrap_or_default();
let mut context_binding = serde_json::json!({
"project": project_as_authored,
"binding_mode": top_binding_mode,
});
if was_introspected(context) {
context_binding["resolved"] = resolved_binding(context);
}
let mut config = serde_json::json!({});
if let Some(ceiling) = &profile.config.capability_ceiling {
config["capability_ceiling"] = serde_json::json!(ceiling);
}
let mut ir = serde_json::json!({
"warble_ir_version": "0.7",
"profile": profile.profile,
"context_binding": context_binding,
"config": config,
"components": component_nodes,
});
if !profile.slots.is_empty() {
ir["slots"] = render_slots(
&profile.slots,
Some(&slot_contents.profile),
project_as_authored,
)?;
}
Ok(ir)
}
type ResolvedArgsList = Vec<Option<HashMap<String, serde_yaml::Value>>>;
fn evaluate_preconditions(
component: &ComponentFile,
context: &dyn ContextLoader,
binds: &HashMap<String, serde_yaml::Value>,
) -> Result<(Vec<serde_json::Value>, ResolvedArgsList), CompileError> {
let mut checks = Vec::with_capacity(component.context_precondition.len());
let mut resolved_args_list = Vec::with_capacity(component.context_precondition.len());
for precondition in &component.context_precondition {
let predicate = precondition.predicate.as_str();
let resolved_args =
match resolve_precondition_args(precondition.args.as_ref(), component, binds)? {
ArgResolution::Unresolvable(reason) => {
return Err(CompileError(format!(
"context precondition '{predicate}' on component '{}' cannot be evaluated \
against the bound semantic layer: {reason}. Refusing rather than \
answering wrongly.",
component.id
)));
}
ArgResolution::Resolved(args) => args,
};
match eval_predicate(predicate, resolved_args.as_ref(), context) {
PredicateOutcome::Unanswerable(reason) => {
return Err(CompileError(format!(
"context precondition '{predicate}' on component '{}' cannot be evaluated \
against the bound semantic layer: {reason}. Refusing rather than answering \
wrongly.",
component.id
)));
}
PredicateOutcome::Fail => {
return Err(CompileError(format!(
"context precondition '{predicate}' not satisfied by the bound semantic layer \
for component '{}'",
component.id
)));
}
PredicateOutcome::Pass => {
checks.push(serde_json::json!({ "predicate": predicate, "outcome": "pass" }));
}
}
resolved_args_list.push(resolved_args);
}
Ok((checks, resolved_args_list))
}
fn resolve_binds(
component: &ComponentFile,
mount: &ProfileComponentMount,
) -> HashMap<String, serde_yaml::Value> {
let mut binds = HashMap::new();
for param in &component.params {
if param.source.is_some() {
continue;
}
let supplied = mount.bind.as_ref().and_then(|b| b.get(¶m.name));
if let Some(value) = supplied {
binds.insert(param.name.clone(), value.clone());
} else if let Some(default) = ¶m.default {
binds.insert(param.name.clone(), default.clone());
}
}
binds
}
enum ArgResolution {
Resolved(Option<HashMap<String, serde_yaml::Value>>),
Unresolvable(String),
}
fn resolve_precondition_args(
args: Option<&HashMap<String, serde_yaml::Value>>,
component: &ComponentFile,
binds: &HashMap<String, serde_yaml::Value>,
) -> Result<ArgResolution, CompileError> {
let Some(args) = args else {
return Ok(ArgResolution::Resolved(None));
};
let mut resolved = HashMap::with_capacity(args.len());
for (key, value) in args {
let Some(param_name) = value.as_str().and_then(|s| s.strip_prefix("$param:")) else {
resolved.insert(key.clone(), value.clone());
continue;
};
if !component.params.iter().any(|p| p.name == param_name) {
return Err(CompileError(format!(
"precondition arg '{key}' on component '{}' references '$param:{param_name}', but \
'{param_name}' is not a declared param of this component",
component.id
)));
}
match binds.get(param_name) {
Some(resolved_value) => {
resolved.insert(key.clone(), resolved_value.clone());
}
None => {
return Ok(ArgResolution::Unresolvable(format!(
"arg '{key}' references '$param:{param_name}', which has no bound value and \
no declared default"
)));
}
}
}
Ok(ArgResolution::Resolved(Some(resolved)))
}
enum PredicateOutcome {
Pass,
Fail,
Unanswerable(String),
}
fn eval_predicate(
predicate: &str,
args: Option<&HashMap<String, serde_yaml::Value>>,
context: &dyn ContextLoader,
) -> PredicateOutcome {
match eval_predicate_uncensored(predicate, args, context) {
already @ PredicateOutcome::Unanswerable(_) => already,
answered if context.can_answer(predicate) => answered,
_ => PredicateOutcome::Unanswerable(format!(
"the bound Context does not answer '{predicate}'"
)),
}
}
fn eval_predicate_uncensored(
predicate: &str,
args: Option<&HashMap<String, serde_yaml::Value>>,
context: &dyn ContextLoader,
) -> PredicateOutcome {
let boolean = boolean_outcome;
match predicate {
"mdl_parseable" | "wren_project_exists" => boolean(context.is_parseable()),
"has_metric" => boolean(!context.metrics().is_empty()),
"has_queryable_dimension" | "has_groupable_dimension" => {
boolean(!context.dimensions().is_empty())
}
"has_time_dimension" => boolean(!context.time_dimensions().is_empty()),
"model_has_timestamp" => eval_model_has_timestamp(args, context),
"lineage_resolvable" => boolean(context.lineage().is_resolvable()),
"metric_additive" => eval_metric_additive(args, context),
"source_introspectable" => {
raw_shape(context.source_introspectable(), "source_introspectable")
}
"raw_docs_readable" => raw_shape(context.raw_docs_readable(), "raw_docs_readable"),
other => PredicateOutcome::Unanswerable(format!("unknown predicate '{other}'")),
}
}
fn boolean_outcome(b: bool) -> PredicateOutcome {
if b {
PredicateOutcome::Pass
} else {
PredicateOutcome::Fail
}
}
fn eval_model_has_timestamp(
args: Option<&HashMap<String, serde_yaml::Value>>,
context: &dyn ContextLoader,
) -> PredicateOutcome {
if let Some(name) = args.and_then(|a| a.get("model")).and_then(|v| v.as_str()) {
return match context.model(name) {
Some(model) => boolean_outcome(model.has_timestamp),
None => PredicateOutcome::Unanswerable(format!(
"model '{name}' is not a declared model, so its timestamp presence is undefined"
)),
};
}
boolean_outcome(context.models().iter().any(|m| m.has_timestamp))
}
fn raw_shape(probe: Option<bool>, predicate: &str) -> PredicateOutcome {
match probe {
Some(true) => PredicateOutcome::Pass,
Some(false) => PredicateOutcome::Fail,
None => PredicateOutcome::Unanswerable(format!(
"the bound Context does not probe a raw source, so '{predicate}' (a constitutive \
raw-shape predicate) is not expressible against it"
)),
}
}
fn eval_metric_additive(
args: Option<&HashMap<String, serde_yaml::Value>>,
context: &dyn ContextLoader,
) -> PredicateOutcome {
if let Some(name) = args.and_then(|a| a.get("metric")).and_then(|v| v.as_str()) {
return match context.metric_additivity(name) {
Some(Additivity::Additive) | Some(Additivity::SemiAdditive) => PredicateOutcome::Pass,
Some(Additivity::NonAdditive) => PredicateOutcome::Fail,
None => PredicateOutcome::Unanswerable(format!(
"metric '{name}' is not a declared metric, so its additivity is undefined"
)),
};
}
if !context.metrics().iter().any(|m| m.declared) {
return PredicateOutcome::Unanswerable(
"no declared metric exists, so additivity is not expressible".to_string(),
);
}
let any_additive = context.metrics().iter().any(|m| {
matches!(
m.additivity,
Some(Additivity::Additive) | Some(Additivity::SemiAdditive)
)
});
if any_additive {
PredicateOutcome::Pass
} else {
PredicateOutcome::Fail
}
}
fn was_introspected(context: &dyn ContextLoader) -> bool {
PRECONDITION_VOCABULARY
.iter()
.any(|predicate| context.can_answer(predicate))
}
fn resolved_binding(context: &dyn ContextLoader) -> serde_json::Value {
let metrics: Vec<serde_json::Value> = context
.metrics()
.iter()
.map(|m| {
let mut node = serde_json::json!({ "name": m.name, "declared": m.declared });
if let Some(additivity) = m.additivity {
node["additivity"] = serde_json::json!(additivity_str(additivity));
}
node
})
.collect();
let dimensions: Vec<serde_json::Value> = context
.dimensions()
.iter()
.map(|d| serde_json::json!({ "name": d.name, "temporal": d.is_temporal }))
.collect();
let time_dimensions: Vec<&str> = context
.time_dimensions()
.iter()
.map(|d| d.name.as_str())
.collect();
let models: Vec<&str> = context.models().iter().map(|m| m.name.as_str()).collect();
let lineage = context.lineage();
let mut lineage_json = serde_json::json!({
"nodes": lineage.nodes.len(),
"edges": lineage.edges.len(),
"resolvable": lineage.is_resolvable(),
});
let count_kind =
|kind: crate::context::LineageKind| lineage.nodes.iter().filter(|n| n.kind == kind).count();
let queries = count_kind(crate::context::LineageKind::Query);
let dashboards = count_kind(crate::context::LineageKind::Dashboard);
if queries + dashboards > 0 {
lineage_json["consumers"] =
serde_json::json!({ "queries": queries, "dashboards": dashboards });
}
let diagnostics = context.lineage_diagnostics();
if !diagnostics.is_empty() {
lineage_json["diagnostics"] = serde_json::json!(diagnostics);
}
serde_json::json!({
"metrics": metrics,
"dimensions": dimensions,
"time_dimensions": time_dimensions,
"models": models,
"lineage": lineage_json,
})
}
fn additivity_str(additivity: Additivity) -> &'static str {
match additivity {
Additivity::Additive => "additive",
Additivity::SemiAdditive => "semi_additive",
Additivity::NonAdditive => "non_additive",
}
}
fn check_required_binds(
component: &ComponentFile,
mount: &ProfileComponentMount,
) -> Result<(), CompileError> {
for param in &component.params {
if param.bind.as_deref() == Some("required") {
let supplied = mount
.bind
.as_ref()
.map(|binds| binds.contains_key(¶m.name))
.unwrap_or(false);
if !supplied {
return Err(CompileError(format!(
"missing required bind '{}' for component '{}'",
param.name, component.id
)));
}
}
}
Ok(())
}
fn check_precondition_vocabulary(component: &ComponentFile) -> Result<(), CompileError> {
for precondition in &component.context_precondition {
if !PRECONDITION_VOCABULARY.contains(&precondition.predicate.as_str()) {
return Err(CompileError(format!(
"unknown context_precondition predicate '{}' on component '{}' (known: {})",
precondition.predicate,
component.id,
PRECONDITION_VOCABULARY.join(", ")
)));
}
}
Ok(())
}
fn check_capability_ceiling(
component: &ComponentFile,
profile: &ProfileFile,
) -> Result<(), CompileError> {
let Some(ceiling) = &profile.config.capability_ceiling else {
return Ok(());
};
let ceiling_set: HashSet<&str> = ceiling.iter().map(String::as_str).collect();
for capability in &component.required_capabilities {
if !ceiling_set.contains(capability.as_str()) {
return Err(CompileError(format!(
"component '{}' requires capability '{}', which is outside the profile's \
capability_ceiling ({})",
component.id,
capability,
ceiling.join(", ")
)));
}
}
Ok(())
}
fn check_when_guards(component: &ComponentFile) -> Result<(), CompileError> {
for step in &component.llm_steps {
match (&step.conditional, &step.when) {
(true, None) => {
return Err(CompileError(format!(
"conditional step '{}' on component '{}' has no 'when' guard — bare \
'conditional: true' no longer implies a condition; declare 'when: {{ guard: \
..., target: ... }}' (known guards: {})",
step.name,
component.id,
GUARD_VOCABULARY.join(", ")
)));
}
(false, Some(_)) => {
return Err(CompileError(format!(
"step '{}' on component '{}' declares a 'when' guard but is not \
'conditional: true' — a guard with nothing to guard is refused rather than \
silently ignored",
step.name, component.id
)));
}
(true, Some(when)) => validate_when_guard(when, step, component)?,
(false, None) => {}
}
}
Ok(())
}
fn validate_when_guard(
when: &WhenGuard,
step: &crate::model::LlmStep,
component: &ComponentFile,
) -> Result<(), CompileError> {
if !GUARD_VOCABULARY.contains(&when.guard.as_str()) {
return Err(CompileError(format!(
"unknown guard '{}' in step '{}' of component '{}' (known: {})",
when.guard,
step.name,
component.id,
GUARD_VOCABULARY.join(", ")
)));
}
if when.target.trim().is_empty() {
return Err(CompileError(format!(
"guard '{}' in step '{}' of component '{}' has an empty target",
when.guard, step.name, component.id
)));
}
if when.guard == "on_flag" && !when.target.contains('.') {
return Err(CompileError(format!(
"guard 'on_flag' in step '{}' of component '{}' expects a dotted \
'artifact.field' target, got '{}'",
step.name, component.id, when.target
)));
}
Ok(())
}
fn check_step_dataflow(component: &ComponentFile) -> Result<(), CompileError> {
let mut all_step_names: HashSet<&str> = HashSet::new();
let mut all_produces: HashSet<&str> = HashSet::new();
let mut earlier_step_names: HashSet<&str> = HashSet::new();
let mut produced_so_far: HashSet<&str> = HashSet::new();
for step in &component.llm_steps {
if !all_step_names.insert(step.name.as_str()) {
return Err(CompileError(format!(
"duplicate step name '{}' on component '{}' — step names must be unique within a \
component",
step.name, component.id
)));
}
if let Some(produces) = &step.produces {
if !all_produces.insert(produces.as_str()) {
return Err(CompileError(format!(
"duplicate 'produces' artifact '{}' on component '{}' — more than one step \
declares it, most recently '{}'; each artifact must have exactly one \
producer",
produces, component.id, step.name
)));
}
}
for consumed in &step.consumes {
if step.produces.as_deref() == Some(consumed.as_str()) {
return Err(CompileError(format!(
"step '{}' on component '{}' consumes '{}', its own 'produces' artifact — a \
step cannot consume what it produces; 'consumes' must name an earlier step's \
output",
step.name, component.id, consumed
)));
}
if !produced_so_far.contains(consumed.as_str()) {
return Err(CompileError(format!(
"step '{}' on component '{}' consumes '{}', which no earlier step produces — \
add a preceding step with 'produces: {}', or remove it from 'consumes'",
step.name, component.id, consumed, consumed
)));
}
}
if let Some(when) = &step.when {
match when.guard.as_str() {
"on_failure" => {
if !earlier_step_names.contains(when.target.as_str()) {
return Err(CompileError(format!(
"guard 'on_failure' in step '{}' of component '{}' targets step \
'{}', which is not a strictly-earlier step of this component — \
'on_failure' can only observe the outcome of a step that has already \
run",
step.name, component.id, when.target
)));
}
}
"on_missing" => {
if !produced_so_far.contains(when.target.as_str()) {
return Err(CompileError(format!(
"guard 'on_missing' in step '{}' of component '{}' targets artifact \
'{}', which no earlier step produces",
step.name, component.id, when.target
)));
}
}
"on_flag" => {
let artifact = when
.target
.split('.')
.next()
.unwrap_or(when.target.as_str());
if !produced_so_far.contains(artifact) {
return Err(CompileError(format!(
"guard 'on_flag' in step '{}' of component '{}' targets '{}', but \
artifact '{}' is not produced by any earlier step",
step.name, component.id, when.target, artifact
)));
}
}
_ => {} }
}
earlier_step_names.insert(step.name.as_str());
if let Some(produces) = &step.produces {
produced_so_far.insert(produces.as_str());
}
}
Ok(())
}
fn check_step_capabilities(component: &ComponentFile) -> Result<(), CompileError> {
let allowed: HashSet<&str> = component
.required_capabilities
.iter()
.map(String::as_str)
.collect();
for step in &component.llm_steps {
for capability in &step.capabilities {
if !allowed.contains(capability.as_str()) {
return Err(CompileError(format!(
"step '{}' on component '{}' declares capability '{}', which is not in the \
component's 'required_capabilities' — a step's capabilities must be a subset \
of the component's; known: {:?}",
step.name, component.id, capability, component.required_capabilities
)));
}
}
if step.produces_exclusive && step.produces.is_none() {
return Err(CompileError(format!(
"step '{}' on component '{}' declares 'produces_exclusive' but produces no \
artifact — exclusivity is a claim about a 'produces' name, so there is nothing \
for it to apply to",
step.name, component.id
)));
}
}
Ok(())
}
fn check_selector_fields(component: &ComponentFile) -> Result<(), CompileError> {
let described = component
.description
.as_deref()
.map(str::trim)
.is_some_and(|description| !description.is_empty());
let has_examples = component
.examples
.iter()
.any(|example| !example.trim().is_empty());
if has_examples && !described {
return Err(CompileError(format!(
"component '{}' authors 'examples' without a 'description'; every consumer reaches the \
examples through the description, so they would be silently dropped",
component.id
)));
}
let fields = component
.description
.as_deref()
.map(|description| ("description", description))
.into_iter()
.chain(component.examples.iter().map(|e| ("examples", e.as_str())));
for (field, value) in fields {
if value.contains("{{") {
return Err(CompileError(format!(
"component '{}' uses a '{{{{...}}}}' placeholder in '{field}', which takes no \
substitution — it describes the component to readers with no bound project, so the \
placeholder would never be rendered",
component.id
)));
}
}
Ok(())
}
fn check_param_sources(component: &ComponentFile) -> Result<(), CompileError> {
for param in &component.params {
match (¶m.bind, ¶m.source) {
(Some(_), Some(_)) => {
return Err(CompileError(format!(
"param '{}' on component '{}' declares both 'bind' and 'source' (exactly one required)",
param.name, component.id
)));
}
(None, None) => {
return Err(CompileError(format!(
"param '{}' on component '{}' declares neither 'bind' nor 'source' (exactly one required)",
param.name, component.id
)));
}
(None, Some(source)) if source != "runtime-injected" => {
return Err(CompileError(format!(
"param '{}' on component '{}' has unknown source '{}' (only 'runtime-injected' is supported)",
param.name, component.id, source
)));
}
_ => {}
}
}
Ok(())
}
fn resolve_guardrails(
component: &ComponentFile,
mount: &ProfileComponentMount,
) -> Result<Vec<serde_json::Value>, CompileError> {
let patches = mount.guardrails.as_ref();
component
.guardrails
.iter()
.map(|guardrail| {
let mut locked = resolve_guardrail_locked(guardrail, component)?;
let patch = patches.and_then(|p| p.get(&guardrail.name));
if let Some(patch) = patch {
if locked {
return Err(CompileError(format!(
"cannot override locked guardrail '{}' on component '{}'",
guardrail.name, component.id
)));
}
if let Some(new_locked) = patch.locked {
locked = new_locked;
}
}
let mut node = serde_json::json!({ "name": guardrail.name, "locked": locked });
if let Some(scope) = &guardrail.scope {
node["scope"] = serde_json::json!(scope);
}
if let Some(threshold) = &guardrail.threshold {
node["threshold"] = serde_json::json!(threshold);
}
Ok(node)
})
.collect()
}
fn resolve_guardrail_locked(
guardrail: &Guardrail,
component: &ComponentFile,
) -> Result<bool, CompileError> {
match (guardrail.locked, guardrail.overridable) {
(Some(locked), None) => Ok(locked),
(None, Some(overridable)) => Ok(!overridable),
(Some(locked), Some(overridable)) => {
if locked != overridable {
Ok(locked)
} else {
Err(CompileError(format!(
"guardrail '{}' on component '{}' declares contradictory locked/overridable",
guardrail.name, component.id
)))
}
}
(None, None) => Err(CompileError(format!(
"guardrail '{}' on component '{}' must declare 'locked' or 'overridable'",
guardrail.name, component.id
))),
}
}
fn precondition_json(
preconditions: &[Precondition],
resolved_args: &[Option<HashMap<String, serde_yaml::Value>>],
) -> Vec<serde_json::Value> {
preconditions
.iter()
.zip(resolved_args)
.map(|(precondition, args)| {
let mut node = serde_json::json!({ "predicate": precondition.predicate });
if let Some(args) = args {
node["args"] = serde_json::json!(args);
}
node
})
.collect()
}
fn params_json(params: &[Param]) -> Vec<serde_json::Value> {
params
.iter()
.map(|param| {
if let Some(source) = ¶m.source {
serde_json::json!({ "name": param.name, "source": source })
} else {
let mut node = serde_json::json!({ "name": param.name, "bind": param.bind });
if let Some(default) = ¶m.default {
node["default"] = serde_json::json!(default);
}
node
}
})
.collect()
}
fn outcome_json(outcome: &Outcome) -> serde_json::Value {
let mut node = serde_json::json!({ "kind": outcome.kind });
if let Some(verdict_type) = &outcome.verdict_type {
node["verdict_type"] = serde_json::json!(verdict_type);
}
if let Some(emits) = &outcome.emits {
node["emits"] = serde_json::json!(emits);
}
if let Some(target) = &outcome.target {
node["target"] = serde_json::json!(target);
}
if let Some(change_type) = &outcome.change_type {
node["change_type"] = serde_json::json!(change_type);
}
if let Some(routable_scope) = &outcome.routable_scope {
node["routable_scope"] = serde_json::json!(routable_scope);
}
node
}
fn render_blocks_json(render_blocks: &[RenderBlock]) -> Vec<serde_json::Value> {
render_blocks
.iter()
.map(|block| {
serde_json::json!({
"type": block.block_type,
"fields": block.fields,
})
})
.collect()
}
fn resolve_llm_calls(
component: &ComponentFile,
mount: &ProfileComponentMount,
project_as_authored: &str,
step_contents: &HashMap<String, String>,
) -> Result<Vec<serde_json::Value>, CompileError> {
component
.llm_steps
.iter()
.map(|step| {
let tier = mount
.tier_overrides
.as_ref()
.and_then(|overrides| overrides.get(&step.name))
.cloned()
.unwrap_or_else(|| step.tier.clone());
let prompt = render_step_body(component, step, project_as_authored, step_contents)?;
let when = step
.when
.as_ref()
.map(|w| serde_json::json!({ "guard": w.guard, "target": w.target }));
let mut call = serde_json::json!({
"name": step.name,
"tier": tier,
"consumes": step.consumes,
"produces": step.produces,
"conditional": step.conditional,
"when": when,
"prompt": prompt,
});
if !step.capabilities.is_empty() {
call["capabilities"] = serde_json::json!(step.capabilities);
}
if step.produces_exclusive {
call["produces_exclusive"] = serde_json::json!(true);
}
Ok(call)
})
.collect()
}
fn slot_references(text: &str) -> Vec<String> {
double_brace_bodies(text)
.into_iter()
.filter_map(|body| body.trim().strip_prefix("slot."))
.filter(|name| is_slot_name(name))
.map(str::to_string)
.collect()
}
fn check_template_syntax(raw: &str, owner: &str) -> Result<(), CompileError> {
if let Some(offset) = raw.find("{%").or_else(|| raw.find("{#")) {
let delimiter = &raw[offset..offset + 2];
return Err(CompileError(format!(
"unrecognised template syntax in {owner}: '{delimiter}' is a template statement or \
comment delimiter, which Warble does not support. A single brace needs no escaping — \
write '{{' if you meant a literal one."
)));
}
for reference in double_brace_bodies(raw) {
let body = reference.trim();
let recognised = body == "project"
|| body == "project_name"
|| body.strip_prefix("slot.").is_some_and(is_slot_name);
if !recognised {
return Err(CompileError(format!(
"unrecognised template syntax in {owner}: '{{{{{reference}}}}}' is not a known \
placeholder ('{{{{project}}}}', '{{{{project_name}}}}') or a slot reference \
('{{{{ slot.<name> }}}}'). A single brace needs no escaping — write '{{' if you \
meant a literal one."
)));
}
}
Ok(())
}
fn double_brace_bodies(raw: &str) -> Vec<&str> {
let mut bodies = Vec::new();
let mut cursor = 0;
while let Some(open) = raw[cursor..].find("{{") {
let body_start = cursor + open + 2;
match raw[body_start..].find("}}") {
Some(close) => {
bodies.push(&raw[body_start..body_start + close]);
cursor = body_start + close + 2;
}
None => break,
}
}
bodies
}
fn is_slot_name(name: &str) -> bool {
let mut chars = name.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c == '_')
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn check_component_slots(
component: &ComponentFile,
mount: &ProfileComponentMount,
step_contents: Option<&HashMap<String, String>>,
slot_contents: Option<&HashMap<String, HashMap<String, String>>>,
) -> Result<(), CompileError> {
if component.slots.is_empty() {
let referenced = component_slot_reference_names(component, mount, step_contents, None);
if let Some(name) = referenced.first() {
return Err(CompileError(format!(
"component '{}' references slot '{name}' in its prompt text but declares no slots",
component.id
)));
}
return Ok(());
}
let mut declared: Vec<&str> = Vec::with_capacity(component.slots.len());
for slot in &component.slots {
if declared.contains(&slot.name.as_str()) {
return Err(CompileError(format!(
"component '{}' declares slot '{}' more than once",
component.id, slot.name
)));
}
declared.push(&slot.name);
check_slot_shape(slot, &format!("component '{}'", component.id))?;
}
for slot in &component.slots {
let variants = slot_contents.and_then(|all| all.get(&slot.name));
for key in slot.variants.keys() {
if variants.and_then(|v| v.get(key)).is_none() {
return Err(CompileError(format!(
"missing content for variant '{key}' of slot '{}' in component '{}'",
slot.name, component.id
)));
}
}
}
let referenced = component_slot_reference_names(component, mount, step_contents, slot_contents);
for name in &referenced {
if !declared.contains(&name.as_str()) {
return Err(CompileError(format!(
"component '{}' references slot '{name}' in its prompt text, which it does not \
declare (declared: {})",
component.id,
declared.join(", ")
)));
}
}
for slot in &component.slots {
if !referenced.contains(&slot.name) {
return Err(CompileError(format!(
"component '{}' declares slot '{}' but no prompt text references \
'{{{{ slot.{} }}}}'",
component.id, slot.name, slot.name
)));
}
}
Ok(())
}
fn check_profile_slots(
profile: &ProfileFile,
components: &HashMap<String, ComponentFile>,
slot_contents: &HashMap<String, HashMap<String, String>>,
) -> Result<(), CompileError> {
let mut declared: Vec<&str> = Vec::with_capacity(profile.slots.len());
for slot in &profile.slots {
if declared.contains(&slot.name.as_str()) {
return Err(CompileError(format!(
"profile '{}' declares slot '{}' more than once",
profile.profile, slot.name
)));
}
declared.push(&slot.name);
check_slot_shape(slot, &format!("profile '{}'", profile.profile))?;
for mount in &profile.components {
let Some(component) = components.get(&mount.use_id) else {
continue;
};
if component.slots.iter().any(|s| s.name == slot.name) {
return Err(CompileError(format!(
"profile '{}' declares slot '{}', which component '{}' also declares — slot \
names are shared across the whole project, so rename one of them",
profile.profile, slot.name, component.id
)));
}
}
}
for slot in &profile.slots {
for key in slot.variants.keys() {
if slot_contents
.get(&slot.name)
.and_then(|v| v.get(key))
.is_none()
{
return Err(CompileError(format!(
"missing content for variant '{key}' of slot '{}' in profile '{}'",
slot.name, profile.profile
)));
}
}
}
let mut referenced = Vec::new();
if let Some(system_prompt) = profile.system_prompt.as_deref() {
referenced.extend(slot_references(system_prompt));
}
for slot in &profile.slots {
let mut keys: Vec<&String> = slot.variants.keys().collect();
keys.sort_unstable();
for key in keys {
if let Some(content) = slot_contents.get(&slot.name).and_then(|v| v.get(key)) {
referenced.extend(slot_references(content));
}
}
}
for name in &referenced {
if !declared.contains(&name.as_str()) {
return Err(CompileError(format!(
"profile '{}' references slot '{name}' in its system_prompt, which it does not \
declare (declared: {})",
profile.profile,
declared.join(", ")
)));
}
}
for slot in &profile.slots {
if !referenced.contains(&slot.name) {
return Err(CompileError(format!(
"profile '{}' declares slot '{}' but its system_prompt does not reference \
'{{{{ slot.{} }}}}'",
profile.profile, slot.name, slot.name
)));
}
}
Ok(())
}
fn check_slot_shape(slot: &SlotDecl, owner: &str) -> Result<(), CompileError> {
if !is_slot_name(&slot.name) {
return Err(CompileError(format!(
"{owner} declares slot '{}', which is not a usable slot name — a name must match \
[a-z_][a-z0-9_]* to be referenced as '{{{{ slot.<name> }}}}'",
slot.name
)));
}
if slot.variants.is_empty() {
return Err(CompileError(format!(
"{owner} declares slot '{}' with no variants",
slot.name
)));
}
if !slot.variants.contains_key(&slot.default) {
let mut keys: Vec<&str> = slot.variants.keys().map(String::as_str).collect();
keys.sort_unstable();
return Err(CompileError(format!(
"{owner} declares slot '{}' with default '{}', which is not one of its variants ({})",
slot.name,
slot.default,
keys.join(", ")
)));
}
Ok(())
}
fn component_slot_reference_names(
component: &ComponentFile,
mount: &ProfileComponentMount,
step_contents: Option<&HashMap<String, String>>,
slot_contents: Option<&HashMap<String, HashMap<String, String>>>,
) -> Vec<String> {
let mut names = Vec::new();
if let Some(steps) = step_contents {
for step in &component.llm_steps {
if let Some(body) = steps.get(&step.name) {
names.extend(slot_references(body));
}
}
}
if let Some(brief) = mount.brief.as_deref().or(component.brief.as_deref()) {
names.extend(slot_references(brief));
}
if let Some(all) = slot_contents {
for slot in &component.slots {
let mut keys: Vec<&String> = slot.variants.keys().collect();
keys.sort_unstable();
for key in keys {
if let Some(content) = all.get(&slot.name).and_then(|v| v.get(key)) {
names.extend(slot_references(content));
}
}
}
}
names
}
fn render_slots(
slots: &[SlotDecl],
contents: Option<&HashMap<String, HashMap<String, String>>>,
project_as_authored: &str,
) -> Result<serde_json::Value, CompileError> {
let mut out = Vec::with_capacity(slots.len());
for slot in slots {
let mut rendered = serde_json::Map::new();
for key in slot.variants.keys() {
let raw = contents
.and_then(|all| all.get(&slot.name))
.and_then(|v| v.get(key))
.ok_or_else(|| {
CompileError(format!(
"missing content for variant '{key}' of slot '{}'",
slot.name
))
})?;
rendered.insert(
key.clone(),
serde_json::json!(render_placeholders(
raw,
project_as_authored,
&format!("variant '{key}' of slot '{}'", slot.name),
)?),
);
}
let mut node = serde_json::json!({
"name": slot.name,
"default": slot.default,
"variants": serde_json::Value::Object(rendered),
});
if let Some(present_when) = slot.present_when.as_deref() {
node["present_when"] = serde_json::json!(present_when);
}
out.push(node);
}
Ok(serde_json::Value::Array(out))
}
fn render_assets(assets: &[AssetDecl]) -> Result<serde_json::Value, CompileError> {
let mut out = Vec::with_capacity(assets.len());
for asset in assets {
let hash = asset.hash.as_deref().ok_or_else(|| {
CompileError(format!(
"asset '{}' reached compile without a computed hash",
asset.path
))
})?;
let bytes = asset.bytes.ok_or_else(|| {
CompileError(format!(
"asset '{}' reached compile without a computed size",
asset.path
))
})?;
out.push(serde_json::json!({
"path": asset.path,
"hash": hash,
"bytes": bytes,
}));
}
Ok(serde_json::Value::Array(out))
}
fn render_placeholders(
raw: &str,
project_as_authored: &str,
owner: &str,
) -> Result<String, CompileError> {
check_template_syntax(raw, owner)?;
let project_name = project_basename(project_as_authored);
Ok(raw
.trim_end()
.replace("{{project}}", project_as_authored)
.replace("{{project_name}}", &project_name)
.trim_end()
.to_string())
}
fn render_step_body(
component: &ComponentFile,
step: &crate::model::LlmStep,
project_as_authored: &str,
step_contents: &HashMap<String, String>,
) -> Result<String, CompileError> {
let raw = step_contents.get(&step.name).ok_or_else(|| {
CompileError(format!(
"missing prompt content for step '{}' of component '{}'",
step.name, component.id
))
})?;
render_placeholders(
raw,
project_as_authored,
&format!("step '{}' of component '{}'", step.name, component.id),
)
}
fn render_brief(
component: &ComponentFile,
mount: &ProfileComponentMount,
profile_system_prompt: Option<&str>,
project_as_authored: &str,
) -> Result<Option<String>, CompileError> {
let own = match (mount.brief.as_deref(), component.brief.as_deref()) {
(Some(raw), _) => Some(render_placeholders(
raw,
project_as_authored,
&format!("the mount brief for component '{}'", component.id),
)?),
(None, Some(raw)) => Some(render_placeholders(
raw,
project_as_authored,
&format!("the brief of component '{}'", component.id),
)?),
(None, None) => None,
};
let shared = profile_system_prompt
.map(|raw| render_placeholders(raw, project_as_authored, "the profile's system_prompt"))
.transpose()?
.filter(|rendered| !rendered.is_empty());
Ok(match (shared, own) {
(None, own) => own,
(Some(shared), None) => Some(shared),
(Some(shared), Some(own)) if own.is_empty() => Some(shared),
(Some(shared), Some(own)) => Some(format!("{shared}\n\n{own}")),
})
}
fn render_prompt_fragment(
component: &ComponentFile,
project_as_authored: &str,
step_contents: &HashMap<String, String>,
) -> Result<String, CompileError> {
let mut sections = Vec::with_capacity(component.llm_steps.len());
for step in &component.llm_steps {
let rendered = render_step_body(component, step, project_as_authored, step_contents)?;
sections.push(format!("## {}\n\n{}", step.name, rendered));
}
Ok(sections.join("\n\n"))
}
fn project_basename(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
#[cfg(test)]
mod asset_render_tests {
use super::*;
fn decl(path: &str, hash: Option<&str>, bytes: Option<u64>) -> AssetDecl {
AssetDecl {
path: path.to_string(),
hash: hash.map(str::to_string),
bytes,
}
}
#[test]
fn a_missing_hash_is_a_loud_error_naming_the_asset() {
let err = render_assets(&[decl("themes/dark.css", None, Some(22))])
.expect_err("an unpopulated hash must not reach the IR");
assert!(err.0.contains("themes/dark.css"), "{}", err.0);
assert!(err.0.contains("without a computed hash"), "{}", err.0);
}
#[test]
fn a_missing_size_is_a_loud_error_naming_the_asset() {
let err = render_assets(&[decl("themes/dark.css", Some("sha256:abc"), None)])
.expect_err("an unpopulated size must not reach the IR");
assert!(err.0.contains("themes/dark.css"), "{}", err.0);
assert!(err.0.contains("without a computed size"), "{}", err.0);
}
#[test]
fn a_populated_asset_renders_exactly_path_hash_and_bytes() {
let rendered = render_assets(&[decl("themes/dark.css", Some("sha256:abc"), Some(22))])
.expect("a populated asset must render");
assert_eq!(
rendered,
serde_json::json!([
{ "path": "themes/dark.css", "hash": "sha256:abc", "bytes": 22 }
]),
"the manifest entry must carry these three keys and nothing else"
);
}
}