1use 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
12pub const CONTRACT_PROJECTION_EXTRA: &str = "forge-contract-projection";
14pub const OUTPUT_GRAMMAR_GRAPH_EXTRA: &str = "output-grammar-graph";
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ContractProjectionCaps {
20 pub token_budget: usize,
22 pub include_examples: bool,
24 pub codec: Symbol,
26}
27
28impl ContractProjectionCaps {
29 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct ContractProjection {
42 pub text: String,
44 pub tokens: usize,
46 pub included: usize,
48 pub summary_only: usize,
50 pub dropped: usize,
52 pub diagnostics: Vec<Diagnostic>,
54}
55
56impl ContractProjection {
57 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#[derive(Clone)]
84pub struct AuthorTask {
85 pub name: Symbol,
87 pub goal: String,
89 pub target_codec: Symbol,
91 pub query: ShapeQuery,
93 pub contract_cards: Vec<RankedContractCard>,
95 pub projection_caps: ContractProjectionCaps,
97 pub return_shape_expr: Expr,
99 pub return_shape: Arc<dyn Shape>,
101 pub verifiers: Vec<Symbol>,
103 pub strict_grammar: bool,
105}
106
107pub 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
177pub 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}