Skip to main content

sim_lib_forge/
author.rs

1//! Contract projection and grammar-bearing author requests.
2
3use std::sync::Arc;
4
5use sim_kernel::{Cx, Diagnostic, Error, Expr, Result, ShapeRef, Symbol};
6use sim_lib_agent_runner_core::{ModelRequest, OutputContract, fenced_data_text};
7use sim_shape::{GrammarGraph, Production, Shape, shape_grammar_graph};
8use sim_value::build::{entry, uint};
9
10use crate::{RankedContractCard, ShapeQuery, estimate_prompt_tokens};
11
12/// Model-request extension key for the fenced projected contract payload.
13pub const CONTRACT_PROJECTION_EXTRA: &str = "forge-contract-projection";
14/// Model-request extension key for source SG3 grammar graph metadata.
15pub const OUTPUT_GRAMMAR_GRAPH_EXTRA: &str = "output-grammar-graph";
16
17/// Limits and format switches for contract projection.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ContractProjectionCaps {
20    /// Maximum estimated prompt tokens allowed in the rendered projection.
21    pub token_budget: usize,
22    /// Whether examples may be included when a full card fits.
23    pub include_examples: bool,
24    /// Codec surface the projected contracts are intended to help author.
25    pub codec: Symbol,
26}
27
28impl ContractProjectionCaps {
29    /// Builds projection limits for a target codec and token budget.
30    pub fn new(codec: Symbol, token_budget: usize) -> Self {
31        Self {
32            token_budget,
33            include_examples: true,
34            codec,
35        }
36    }
37}
38
39/// A token-counted model-facing projection of ranked contract cards.
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct ContractProjection {
42    /// Complete rendered projection text that fits the configured budget.
43    pub text: String,
44    /// Estimated prompt tokens in [`Self::text`].
45    pub tokens: usize,
46    /// Cards retained in any representation.
47    pub included: usize,
48    /// Cards reduced all the way to summary-only form.
49    pub summary_only: usize,
50    /// Cards dropped because even their summary-only form did not fit.
51    pub dropped: usize,
52    /// Non-fatal reduction and drop diagnostics.
53    pub diagnostics: Vec<Diagnostic>,
54}
55
56impl ContractProjection {
57    /// Encodes projection metadata as open model-request data.
58    pub fn to_expr(&self) -> Expr {
59        Expr::Map(vec![
60            entry(
61                "kind",
62                Expr::Symbol(Symbol::qualified("forge", "ContractProjection")),
63            ),
64            entry("tokens", uint(self.tokens as u64)),
65            entry("included", uint(self.included as u64)),
66            entry("summary-only", uint(self.summary_only as u64)),
67            entry("dropped", uint(self.dropped as u64)),
68            entry("text", Expr::String(self.text.clone())),
69            entry(
70                "diagnostics",
71                Expr::List(
72                    self.diagnostics
73                        .iter()
74                        .map(|diagnostic| Expr::String(diagnostic.message.clone()))
75                        .collect(),
76                ),
77            ),
78        ])
79    }
80}
81
82/// One contract-native authoring task to send to a model runner.
83#[derive(Clone)]
84pub struct AuthorTask {
85    /// Stable task name for routing, diagnostics, and cassette rows.
86    pub name: Symbol,
87    /// Human goal the authoring request must satisfy.
88    pub goal: String,
89    /// Codec the model must use for the returned checked form.
90    pub target_codec: Symbol,
91    /// Shape query used to retrieve the projected contract cards.
92    pub query: ShapeQuery,
93    /// Ranked contract cards available for the task projection.
94    pub contract_cards: Vec<RankedContractCard>,
95    /// Token and example limits used when projecting contract cards.
96    pub projection_caps: ContractProjectionCaps,
97    /// Normalized expression naming or constructing the return Shape.
98    pub return_shape_expr: Expr,
99    /// Shape the model's returned form must satisfy and the grammar is derived from.
100    pub return_shape: Arc<dyn Shape>,
101    /// Semantic verifier ids that must accept the realized form.
102    pub verifiers: Vec<Symbol>,
103    /// Whether grammar-constrained output is mandatory.
104    pub strict_grammar: bool,
105}
106
107/// Projects ranked contract cards into a bounded, token-counted prompt payload.
108pub fn project_contracts(
109    cards: &[RankedContractCard],
110    caps: &ContractProjectionCaps,
111) -> ContractProjection {
112    project_contracts_with_cards(cards, caps).0
113}
114
115pub(crate) fn project_contracts_with_cards(
116    cards: &[RankedContractCard],
117    caps: &ContractProjectionCaps,
118) -> (ContractProjection, Vec<RankedContractCard>) {
119    let mut parts = Vec::new();
120    let mut projected_cards = Vec::new();
121    let mut included = 0usize;
122    let mut summary_only = 0usize;
123    let mut dropped = 0usize;
124    let mut diagnostics = Vec::new();
125
126    for ranked in cards {
127        let mut chosen = None;
128        for detail in ProjectionDetail::candidates(ranked, caps.include_examples) {
129            let rendered = render_ranked_card(ranked, detail);
130            let candidate_text = append_projection_part(&parts, &rendered);
131            if estimate_prompt_tokens(&candidate_text) <= caps.token_budget {
132                chosen = Some((detail, rendered));
133                break;
134            }
135        }
136
137        match chosen {
138            Some((ProjectionDetail::SummaryOnly, rendered)) => {
139                diagnostics.push(Diagnostic::info(format!(
140                    "contract projection reduced {} to summary only under token budget",
141                    ranked.card.symbol
142                )));
143                parts.push(rendered);
144                projected_cards.push(ranked.clone());
145                included += 1;
146                summary_only += 1;
147            }
148            Some((_, rendered)) => {
149                parts.push(rendered);
150                projected_cards.push(ranked.clone());
151                included += 1;
152            }
153            None => {
154                diagnostics.push(Diagnostic::info(format!(
155                    "contract projection dropped {} under token budget",
156                    ranked.card.symbol
157                )));
158                dropped += 1;
159            }
160        }
161    }
162
163    let text = parts.join("\n\n");
164    (
165        ContractProjection {
166            tokens: estimate_prompt_tokens(&text),
167            text,
168            included,
169            summary_only,
170            dropped,
171            diagnostics,
172        },
173        projected_cards,
174    )
175}
176
177/// Builds a grammar-bearing model request from a task and projected contracts.
178pub fn author_model_request(
179    _cx: &mut Cx,
180    task: &AuthorTask,
181    projection: &ContractProjection,
182) -> Result<ModelRequest> {
183    let shape = task.return_shape.as_ref();
184    let shape_expr = task.return_shape_expr.clone();
185
186    if task.strict_grammar {
187        shape_grammar_graph(shape).map_err(|err| {
188            Error::Eval(format!(
189                "forge author request strict grammar cannot lower return shape: {err}"
190            ))
191        })?;
192    }
193
194    let output = OutputContract::for_shape(
195        task.target_codec.clone(),
196        shape_expr.clone(),
197        shape,
198        task.strict_grammar,
199    );
200    let graph_metadata = output.grammar_graph.as_ref().map(grammar_graph_expr);
201
202    let projection_expr = projection.to_expr();
203    let fenced_projection =
204        fenced_data_text("contract-projection", &projection.text, &projection_expr)?;
205    let mut request = ModelRequest::new(
206        Expr::Map(vec![
207            entry(
208                "kind",
209                Expr::Symbol(Symbol::qualified("forge", "AuthorRequest")),
210            ),
211            entry("name", Expr::Symbol(task.name.clone())),
212            entry("goal", Expr::String(task.goal.clone())),
213            entry("target-codec", Expr::Symbol(task.target_codec.clone())),
214            entry("contract-query", query_expr(&task.query)),
215            entry("return-shape", shape_expr),
216            entry("strict-grammar", Expr::Bool(task.strict_grammar)),
217            entry(
218                "contract-projection",
219                Expr::String(fenced_projection.clone()),
220            ),
221        ]),
222        Vec::new(),
223    );
224
225    request.extra.push(entry(
226        "forge-mode",
227        Expr::Symbol(Symbol::qualified("forge", "author-request")),
228    ));
229    request.extra.push(entry(
230        CONTRACT_PROJECTION_EXTRA,
231        Expr::String(fenced_projection),
232    ));
233    request
234        .extra
235        .push(entry("forge-contract-projection-stats", projection_expr));
236    output.into_extra_entries(&mut request.extra);
237    if let Some(metadata) = graph_metadata {
238        request
239            .extra
240            .push(entry(OUTPUT_GRAMMAR_GRAPH_EXTRA, metadata));
241    }
242
243    Ok(request)
244}
245
246#[derive(Clone, Copy, Debug, PartialEq, Eq)]
247enum ProjectionDetail {
248    FullWithExample,
249    ShapeSummary,
250    SummaryOnly,
251}
252
253impl ProjectionDetail {
254    fn candidates(ranked: &RankedContractCard, include_examples: bool) -> Vec<Self> {
255        let mut candidates = Vec::new();
256        if include_examples && ranked.card.example.is_some() {
257            candidates.push(Self::FullWithExample);
258        }
259        candidates.push(Self::ShapeSummary);
260        candidates.push(Self::SummaryOnly);
261        candidates
262    }
263}
264
265fn append_projection_part(parts: &[String], next: &str) -> String {
266    if parts.is_empty() {
267        next.to_owned()
268    } else {
269        format!("{}\n\n{next}", parts.join("\n\n"))
270    }
271}
272
273fn render_ranked_card(ranked: &RankedContractCard, detail: ProjectionDetail) -> String {
274    let card = &ranked.card;
275    let mut lines = vec![
276        format!("contract: {}", card.symbol),
277        format!("lib: {}", card.lib),
278        format!("kind: {}", card.export_kind),
279        format!("score: {}", ranked.score),
280        format!("summary: {}", render_summary(&card.summary)),
281    ];
282
283    if !matches!(detail, ProjectionDetail::SummaryOnly) {
284        lines.push(format!(
285            "args-shape: {}",
286            render_option_expr(&card.args_shape)
287        ));
288        lines.push(format!(
289            "result-shape: {}",
290            render_option_expr(&card.result_shape)
291        ));
292    }
293
294    if matches!(detail, ProjectionDetail::FullWithExample) {
295        lines.push(format!(
296            "capabilities: {}",
297            render_symbols(&card.capability_symbols)
298        ));
299        lines.push(format!(
300            "card-requires: {}",
301            render_option_expr(&card.card_requires)
302        ));
303        lines.push(format!("example: {}", render_option_expr(&card.example)));
304        if !ranked.reasons.is_empty() {
305            lines.push(format!("rank-reasons: {}", ranked.reasons.join("; ")));
306        }
307    }
308
309    lines.join("\n")
310}
311
312fn render_summary(summary: &str) -> String {
313    let trimmed = summary.trim();
314    if trimmed.is_empty() {
315        "none".to_owned()
316    } else {
317        trimmed.to_owned()
318    }
319}
320
321fn render_symbols(symbols: &[Symbol]) -> String {
322    if symbols.is_empty() {
323        "none".to_owned()
324    } else {
325        symbols
326            .iter()
327            .map(Symbol::to_string)
328            .collect::<Vec<_>>()
329            .join(", ")
330    }
331}
332
333fn render_option_expr(expr: &Option<Expr>) -> String {
334    expr.as_ref()
335        .map(render_expr)
336        .unwrap_or_else(|| "unknown".to_owned())
337}
338
339fn render_expr(expr: &Expr) -> String {
340    match expr {
341        Expr::Nil => "nil".to_owned(),
342        Expr::Bool(value) => value.to_string(),
343        Expr::Number(number) => number.canonical.clone(),
344        Expr::Symbol(symbol) => symbol.to_string(),
345        Expr::Local(symbol) => format!("${symbol}"),
346        Expr::String(value) => format!("{value:?}"),
347        Expr::Bytes(bytes) => format!("#bytes[{}]", bytes.len()),
348        Expr::List(items) => render_sequence("(", ")", items),
349        Expr::Vector(items) => render_sequence("[", "]", items),
350        Expr::Map(entries) => render_map(entries),
351        Expr::Set(items) => render_sequence("#{", "}", items),
352        Expr::Call { operator, args } => {
353            let mut parts = vec![render_expr(operator)];
354            parts.extend(args.iter().map(render_expr));
355            format!("({})", parts.join(" "))
356        }
357        Expr::Infix {
358            operator,
359            left,
360            right,
361        } => format!(
362            "({} {} {})",
363            render_expr(left),
364            operator,
365            render_expr(right)
366        ),
367        Expr::Prefix { operator, arg } => format!("({operator} {})", render_expr(arg)),
368        Expr::Postfix { operator, arg } => format!("({} {operator})", render_expr(arg)),
369        Expr::Block(items) => render_sequence("{", "}", items),
370        Expr::Quote { expr, .. } => format!("'{}", render_expr(expr)),
371        Expr::Annotated { expr, .. } => render_expr(expr),
372        Expr::Extension { tag, payload } => format!("#<{} {}>", tag, render_expr(payload)),
373    }
374}
375
376fn render_sequence(open: &str, close: &str, items: &[Expr]) -> String {
377    let body = items.iter().map(render_expr).collect::<Vec<_>>().join(" ");
378    format!("{open}{body}{close}")
379}
380
381fn render_map(entries: &[(Expr, Expr)]) -> String {
382    let body = entries
383        .iter()
384        .map(|(key, value)| format!("{}: {}", render_expr(key), render_expr(value)))
385        .collect::<Vec<_>>()
386        .join(", ");
387    format!("{{{body}}}")
388}
389
390fn query_expr(query: &ShapeQuery) -> Expr {
391    Expr::Map(vec![
392        entry(
393            "args",
394            query.args.as_ref().map(shape_ref_expr).unwrap_or(Expr::Nil),
395        ),
396        entry(
397            "result",
398            query
399                .result
400                .as_ref()
401                .map(shape_ref_expr)
402                .unwrap_or(Expr::Nil),
403        ),
404        entry("limit", uint(query.limit as u64)),
405    ])
406}
407
408fn shape_ref_expr(shape: &ShapeRef) -> Expr {
409    match shape.object().as_shape().and_then(|shape| shape.symbol()) {
410        Some(symbol) => Expr::Symbol(symbol),
411        None => Expr::String("<anonymous-shape>".to_owned()),
412    }
413}
414
415fn grammar_graph_expr(graph: &GrammarGraph) -> Expr {
416    Expr::Map(vec![
417        entry(
418            "kind",
419            Expr::Symbol(Symbol::qualified("forge", "OutputGrammarGraph")),
420        ),
421        entry("root", Expr::Symbol(production_kind_symbol(&graph.root))),
422        entry("defs", uint(graph.defs.len() as u64)),
423        entry("diagnostics", uint(graph.diagnostics.len() as u64)),
424    ])
425}
426
427fn production_kind_symbol(production: &Production) -> Symbol {
428    let name = match production {
429        Production::Terminal(_) => "terminal",
430        Production::Seq(_) => "seq",
431        Production::Alt(_) => "alt",
432        Production::Repeat { .. } => "repeat",
433        Production::Call { .. } => "call",
434        Production::Ref(_) => "ref",
435    };
436    Symbol::qualified("grammar-production", name)
437}