Skip to main content

rustev_surface/
plan.rs

1//! `plan check | compile | show` (spec 006, 3.4), for every host.
2
3use rustev_contract::Document;
4use rustev_contract::definition::Definition;
5use rustev_contract::execution::ExecutionPolicy;
6use rustev_contract::limits::{DEFINITION_V1, DESCRIPTOR_V1, PLAN_V1};
7use rustev_contract::plan::{BoundCalibration, Plan, PlanExecution, PlanStepDetail};
8use rustev_core::compile::{Category, ShortfallReason};
9use rustev_core::{Compiled, LoadError, Refusal, compile, compile_with};
10use serde_json::{Value, json};
11
12use crate::deps::{self, DepArgs, DepFail, Deps, RulesHook};
13use crate::host::Host;
14use crate::out::{Done, Out, Res, code};
15
16/// Snake case of a refusal category (spec 006, 3.4.2).
17pub fn category_name(c: Category) -> &'static str {
18    match c {
19        Category::Parse => "parse",
20        Category::UnknownOperator => "unknown_operator",
21        Category::KindMismatch => "kind_mismatch",
22        Category::Cycle => "cycle",
23        Category::NoCapableBackend => "no_capable_backend",
24        Category::UncalibratedThreshold => "uncalibrated_threshold",
25        Category::Budget => "budget",
26        Category::UnhandledUnresolved => "unhandled_unresolved",
27        Category::InvalidFallback => "invalid_fallback",
28        Category::CalibrationBinding => "calibration_binding",
29        Category::InvalidDefinition => "invalid_definition",
30        Category::InvalidExecution => "invalid_execution",
31        Category::GradeRequirement => "grade_requirement",
32        Category::UncorroboratedHint => "uncorroborated_hint",
33        Category::SourceBinding => "source_binding",
34        // Spec 033, 3.11.1: printed like every category.
35        Category::UncoveredProvenance => "uncovered_provenance",
36        Category::CallerCannotAct => "caller_cannot_act",
37        Category::InvalidScreening => "invalid_screening",
38        Category::UnscreenedText => "unscreened_text",
39        Category::InvalidAcquisition => "invalid_acquisition",
40        Category::SourceMapping => "source_mapping",
41        Category::ExactOrderSource => "exact_order_source",
42        Category::InvalidGeneration => "invalid_generation",
43        Category::InvalidExtraction => "invalid_extraction",
44        Category::InvalidAcceptance => "invalid_acceptance",
45        Category::UnacceptedModelValue => "unaccepted_model_value",
46        Category::Template => "template",
47        Category::ModelOutputFlow => "model_output_flow",
48    }
49}
50
51fn reason_json(r: &ShortfallReason) -> Value {
52    match r {
53        ShortfallReason::OptionLogprobs => json!({"kind": "option_logprobs"}),
54        ShortfallReason::ScreenProfile => json!({"kind": "screen_profile"}),
55        ShortfallReason::ScreenTextLimit { max, needed } => {
56            json!({"kind": "screen_text_limit", "max": max, "needed": needed})
57        }
58        ShortfallReason::ScreenInputExcess => json!({"kind": "screen_input_excess"}),
59        ShortfallReason::OutputLimit { max, needed } => {
60            json!({"kind": "output_limit", "max": max, "needed": needed})
61        }
62        ShortfallReason::Language { required } => json!({"kind": "language", "required": required}),
63        ShortfallReason::RankMode => json!({"kind": "rank_mode"}),
64        ShortfallReason::MaxCandidates { max, needed } => {
65            json!({"kind": "max_candidates", "max": max, "needed": needed})
66        }
67        ShortfallReason::ListwiseScores => json!({"kind": "listwise_scores"}),
68        ShortfallReason::UnknownBackend => json!({"kind": "unknown_backend"}),
69        ShortfallReason::Operation(op) => json!({"kind": "operation", "operation": op}),
70        ShortfallReason::OutputKind { offered, required } => {
71            json!({"kind": "output_kind", "offered": offered, "required": required})
72        }
73        ShortfallReason::Options { max, needed } => {
74            json!({"kind": "options", "max": max, "needed": needed})
75        }
76        ShortfallReason::InputLimit { max, needed } => {
77            json!({"kind": "input_limit", "max": max, "needed": needed})
78        }
79        ShortfallReason::Determinism { offered, required } => {
80            json!({"kind": "determinism", "offered": offered, "required": required})
81        }
82        ShortfallReason::ArtifactPin { pinned, offered } => {
83            json!({"kind": "artifact_pin", "pinned": pinned, "offered": offered})
84        }
85    }
86}
87
88/// The `refused` output of a compile refusal.
89pub fn refused(command: &str, r: &Refusal) -> Done {
90    let shortfalls: Vec<Value> = r
91        .shortfalls
92        .iter()
93        .map(|s| {
94            json!({
95                "backend_id": s.backend_id,
96                "reasons": s.reasons.iter().map(reason_json).collect::<Vec<_>>(),
97            })
98        })
99        .collect();
100    Done::new(
101        code::REFUSED,
102        Out::new(command, "refused")
103            .set("category", &(r.category as u8))
104            .set("category_name", category_name(r.category))
105            .set("subject", &r.subject)
106            .set("detail", &r.detail)
107            .set("shortfalls", &shortfalls),
108    )
109}
110
111fn parse_refusal(subject: String, detail: String) -> Refusal {
112    Refusal {
113        category: Category::Parse,
114        subject,
115        detail,
116        shortfalls: vec![],
117    }
118}
119
120/// The `refused` output of a load diagnostic (spec 004, 5.2).
121pub fn load_refused(command: &str, e: &LoadError) -> Done {
122    let out = Out::new(command, "refused").set("detail", &e.to_string());
123    let out = match e {
124        LoadError::CompilerChanged {
125            recorded_compiler,
126            current_compiler,
127            recorded_registry,
128            current_registry,
129        } => out.set("load_error", "compiler_changed").set(
130            "identities",
131            &json!({
132                "recorded_compiler": recorded_compiler,
133                "current_compiler": current_compiler,
134                "recorded_registry": recorded_registry,
135                "current_registry": current_registry,
136            }),
137        ),
138        LoadError::MissingDescriptor {
139            backend_id,
140            descriptor,
141        } => out
142            .set("load_error", "missing_descriptor")
143            .set("backend_id", backend_id)
144            .set("descriptor", descriptor),
145        LoadError::MissingCalibration { step, calibration } => out
146            .set("load_error", "missing_calibration")
147            .set("step", step)
148            .set("calibration", calibration),
149        LoadError::PlanMismatch { refusal } => {
150            let out = out.set("load_error", "plan_mismatch");
151            match refusal {
152                Some(r) => out.set(
153                    "refusal",
154                    &json!({
155                        "category": r.category as u8,
156                        "category_name": category_name(r.category),
157                        "subject": r.subject,
158                        "detail": r.detail,
159                    }),
160                ),
161                None => out,
162            }
163        }
164    };
165    Done::new(code::REFUSED, out)
166}
167
168/// Map a dependency failure to its output.
169pub fn dep_failed(command: &str, e: DepFail) -> Done {
170    match e {
171        DepFail::Io(e) => Done::io(command, e),
172        DepFail::Parse { subject, detail } => refused(command, &parse_refusal(subject, detail)),
173        DepFail::Rules { path, detail } => Done::invalid(command, &path, detail),
174    }
175}
176
177/// Read and parse a plan document; a malformed one is a category 1 refusal.
178pub fn read_plan(command: &str, host: &dyn Host, path: &str) -> Result<Plan, Done> {
179    let bytes = host
180        .read(path, PLAN_V1.max_bytes)
181        .map_err(|e| Done::io(command, e))?;
182    #[cfg(not(feature = "parameterized"))]
183    rustev_core::unparameterized::plan_bytes(&bytes).map_err(|r| refused(command, &r))?;
184    Plan::parse(&bytes).map_err(|e| {
185        refused(
186            command,
187            &parse_refusal(format!("plan:{path}"), e.to_string()),
188        )
189    })
190}
191
192/// Load a plan with `load_checked` against `deps`.
193pub fn load_plan(command: &str, plan: &Plan, deps: &Deps) -> Result<Compiled, Done> {
194    Compiled::load_checked(plan, &deps.descriptors, &deps.calibrations)
195        .map_err(|e| load_refused(command, &e))
196}
197
198/// What `plan check` and `plan compile` read.
199#[derive(Debug, Clone, Copy)]
200pub struct CompileArgs<'a> {
201    pub definition: &'a str,
202    pub deps: DepArgs<'a>,
203    pub execution: Option<&'a str>,
204}
205
206fn compile_definition(
207    command: &str,
208    host: &dyn Host,
209    args: CompileArgs<'_>,
210    rules: &mut RulesHook<'_>,
211) -> Result<Compiled, Done> {
212    let def_path = args.definition;
213    let bytes = host
214        .read(def_path, DEFINITION_V1.max_bytes)
215        .map_err(|e| Done::io(command, e))?;
216    compile_definition_bytes(command, host, args, rules, &bytes)
217}
218
219pub(crate) fn compile_definition_bytes(
220    command: &str,
221    host: &dyn Host,
222    args: CompileArgs<'_>,
223    rules: &mut RulesHook<'_>,
224    bytes: &[u8],
225) -> Result<Compiled, Done> {
226    // As `compile_bytes`: a malformed definition is category 1, subject
227    // `definition`.
228    #[cfg(not(feature = "parameterized"))]
229    rustev_core::unparameterized::definition(bytes).map_err(|r| refused(command, &r))?;
230    let def = Definition::parse(bytes)
231        .map_err(|e| refused(command, &parse_refusal("definition".into(), e.to_string())))?;
232    let deps = deps::load(host, args.deps, rules).map_err(|e| dep_failed(command, e))?;
233    let execution = match args.execution {
234        None => None,
235        Some(path) => {
236            let bytes = host
237                .read(path, DESCRIPTOR_V1.max_bytes)
238                .map_err(|e| Done::io(command, e))?;
239            Some(ExecutionPolicy::parse(&bytes).map_err(|e| {
240                refused(
241                    command,
242                    &parse_refusal(format!("execution:{path}"), e.to_string()),
243                )
244            })?)
245        }
246    };
247    match &execution {
248        None => compile(&def, &deps.descriptors, &deps.calibrations),
249        Some(x) => compile_with(&def, &deps.descriptors, &deps.calibrations, x),
250    }
251    .map_err(|r| refused(command, &r))
252}
253
254/// `plan check`: the plan identity of a definition that compiles.
255pub fn check(
256    command: &str,
257    host: &dyn Host,
258    args: CompileArgs<'_>,
259    rules: &mut RulesHook<'_>,
260) -> Res {
261    let c = compile_definition(command, host, args, rules)?;
262    Ok(checked(command, &c))
263}
264
265pub(crate) fn checked(command: &str, c: &Compiled) -> Done {
266    Done::ok(Out::new(command, "ok").set("plan_id", &c.id))
267}
268
269/// `plan compile`: the plan's canonical bytes published as `out`.
270pub fn compile_cmd(
271    command: &str,
272    host: &dyn Host,
273    args: CompileArgs<'_>,
274    rules: &mut RulesHook<'_>,
275    out: &str,
276) -> Res {
277    host.ensure_absent(out).map_err(|e| Done::io(command, e))?;
278    let c = compile_definition(command, host, args, rules)?;
279    publish_compiled(command, host, &c, out)
280}
281
282pub(crate) fn publish_compiled(command: &str, host: &dyn Host, c: &Compiled, out: &str) -> Res {
283    host.create(out, &c.canonical())
284        .map_err(|e| Done::io(command, e))?;
285    Ok(Done::ok(
286        Out::new(command, "compiled")
287            .set("plan_id", &c.id)
288            .set("out", out),
289    ))
290}
291
292fn step_json(step: &rustev_contract::plan::PlanStep) -> Value {
293    let detail = match &step.detail {
294        PlanStepDetail::Exact { op, version } => {
295            json!({"kind": "exact", "op": op, "version": version})
296        }
297        PlanStepDetail::Semantic(b) => json!({
298            "kind": "semantic",
299            "backend_id": b.backend_id,
300            "artifact": b.artifact,
301            "descriptor": b.descriptor,
302            "output": b.output,
303            "requires": b.requires,
304            "normalization": b.normalization,
305            "calibration": match &b.calibration {
306                BoundCalibration::None => json!("none"),
307                BoundCalibration::Bound { id, .. } => json!(id),
308            },
309            "fallback_taken": b.fallback_taken,
310            "max_requests": b.max_requests,
311        }),
312        PlanStepDetail::Unsupported { capability } => {
313            json!({"kind": "unsupported", "capability": capability})
314        }
315        PlanStepDetail::Generate(_) => json!({"kind": "generate"}),
316        PlanStepDetail::Rank(_) => json!({"kind": "rank"}),
317        PlanStepDetail::Select(_) => json!({"kind": "select"}),
318        PlanStepDetail::Corroborate => json!({"kind": "corroborate"}),
319        PlanStepDetail::Acquire => json!({"kind": "acquire"}),
320        PlanStepDetail::Widen => json!({"kind": "widen"}),
321        PlanStepDetail::Extract(_) => json!({"kind": "extract"}),
322        PlanStepDetail::AcceptModelDerived(_) => json!({"kind": "accept_model_derived"}),
323    };
324    json!({"id": step.id, "derivation": step.derivation, "detail": detail})
325}
326
327/// `plan show`: a plan's identities, bindings and steps.
328pub fn show(
329    command: &str,
330    host: &dyn Host,
331    plan: &str,
332    deps_args: DepArgs<'_>,
333    rules: &mut RulesHook<'_>,
334) -> Res {
335    let path = plan;
336    let plan = read_plan(command, host, plan)?;
337    // Without `parameterized`, `read_plan` has already refused any plan with
338    // an instantiation record (spec 050), so this returns at its first line.
339    let out = instantiation(command, path, Out::new(command, "ok"), &plan)?;
340    let deps = deps::load(host, deps_args, rules).map_err(|e| dep_failed(command, e))?;
341    let c = load_plan(command, &plan, &deps)?;
342    let plan = &c.plan;
343    let (execution, fallbacks) = match &plan.execution {
344        PlanExecution::None => (json!("none"), vec![]),
345        PlanExecution::Declared(d) => (
346            json!(d.id),
347            d.fallbacks
348                .iter()
349                .map(|f| {
350                    json!({
351                        "step": f.step,
352                        "target": f.target,
353                        "backend_id": f.backend_id,
354                        "artifact": f.artifact,
355                        "descriptor": f.descriptor,
356                        "output": f.output,
357                    })
358                })
359                .collect(),
360        ),
361    };
362    let def = &plan.definition;
363    let out = out
364        .set("plan_id", &c.id)
365        .set("compiler", &plan.compiler)
366        .set("registry", &plan.registry)
367        .set("definition_id", &plan.definition_id)
368        .set(
369            "definition",
370            &json!({"package": def.package, "name": def.name, "version": def.version}),
371        )
372        .set("execution", &execution)
373        .set("fallbacks", &fallbacks)
374        .set("order", &plan.order)
375        .set(
376            "steps",
377            &plan.steps.iter().map(step_json).collect::<Vec<_>>(),
378        );
379    Ok(Done::ok(actionability(out, plan)))
380}
381
382/// A plan with `actionability` adds its declarations and, per check, each
383/// branch's classes, consequence kind and static party (spec 033, 3.11.2);
384/// every other plan prints exactly as before.
385fn actionability(out: Out, plan: &rustev_contract::plan::Plan) -> Out {
386    let (Some(a), Some(table)) = (&plan.definition.actionability, &plan.actionability) else {
387        return out;
388    };
389    out.set("caller_controlled", &a.caller_controlled)
390        .set(
391            "checks",
392            &table
393                .checks
394                .iter()
395                .map(|c| {
396                    json!({
397                        "id": c.check,
398                        "subject": c.subject,
399                        "branches": c.branches.iter().map(|b| json!({
400                            "classes": b.classes,
401                            "consequence": b.consequence,
402                            "party": b.party,
403                        })).collect::<Vec<_>>(),
404                    })
405                })
406                .collect::<Vec<_>>(),
407        )
408        .set(
409            "constraint_groups",
410            &a.constraint_groups
411                .iter()
412                .map(|g| json!({"name": g.name, "checks": g.checks}))
413                .collect::<Vec<_>>(),
414        )
415        .set(
416            "remediations",
417            &a.remediations
418                .iter()
419                .map(|r| json!({"code": r.code, "for": r.for_party}))
420                .collect::<Vec<_>>(),
421        )
422        .set(
423            "questions",
424            &a.questions.iter().map(|q| &q.id).collect::<Vec<_>>(),
425        )
426}
427
428/// Conservatively leave v3 plans without an instantiation record unchanged.
429fn instantiation(command: &str, path: &str, out: Out, plan: &Plan) -> Result<Out, Done> {
430    let Some(i) = &plan.instantiation else {
431        return Ok(out);
432    };
433    let version = i.template.get("version").and_then(Value::as_str);
434    let version = version
435        .filter(|v| v.parse::<rustev_contract::version::Version>().is_ok())
436        .ok_or_else(|| {
437            refused(
438                command,
439                &parse_refusal(
440                    format!("plan:{path}"),
441                    "instantiation.template.version must be a string containing a valid definition version (MAJOR.MINOR.PATCH without leading zeros or suffixes)".into(),
442                ),
443            )
444        })?;
445    Ok(out.set("plan_schema", &plan.schema).set(
446        "instantiation",
447        &json!({
448            "template_id": i.template_id, "template_version": version,
449            "variant": i.variant, "chain": i.profile,
450            "parameters": i.parameters.iter().map(|p| json!({
451                "name": p.name, "value": p.value, "source": p.source,
452                "site_count": p.sites.len(),
453            })).collect::<Vec<_>>(),
454        }),
455    ))
456}