use crate::context::{Additivity, ContextLoader};
use crate::error::CompileError;
use crate::model::{
ComponentFile, Guardrail, Outcome, Param, Precondition, ProfileComponentMount, ProfileFile,
RenderBlock, WhenGuard,
};
use std::collections::HashMap;
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>>,
) -> 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}"
)));
}
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_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)?;
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, project_as_authored) {
node["brief"] = serde_json::json!(brief);
}
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);
}
Ok(serde_json::json!({
"warble_ir_version": "0.6",
"profile": profile.profile,
"context_binding": context_binding,
"config": {},
"components": component_nodes,
}))
}
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_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_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 }));
Ok(serde_json::json!({
"name": step.name,
"tier": tier,
"consumes": step.consumes,
"produces": step.produces,
"conditional": step.conditional,
"when": when,
"prompt": prompt,
}))
})
.collect()
}
fn render_placeholders(raw: &str, project_as_authored: &str) -> String {
let project_name = project_basename(project_as_authored);
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
))
})?;
Ok(render_placeholders(raw, project_as_authored))
}
fn render_brief(
component: &ComponentFile,
mount: &ProfileComponentMount,
project_as_authored: &str,
) -> Option<String> {
let raw = mount.brief.as_ref().or(component.brief.as_ref())?;
Some(render_placeholders(raw, project_as_authored))
}
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())
}