Skip to main content

sim_lib_forge/
lift_frontier.rs

1use sim_codec_bridge::{
2    BridgeBook, BridgeFramePayload, BridgeHeader, BridgePacket, BridgePart, BridgeProvenance,
3    FrameHoleKind, canonical_packet_datum, content_id_string, packet_content_id, packet_to_expr,
4    stamp_packet_cid,
5};
6use sim_kernel::{
7    Consistency, ContentId, Cx, DatumStore, Error, EvalFabric, EvalMode, EvalRequest, Expr, Result,
8    Symbol,
9};
10use sim_lib_agent_runner_core::{
11    ModelRequest, ModelResponse, OUTPUT_GRAMMAR_DIALECT_EXTRA, OUTPUT_GRAMMAR_EXTRA,
12    OUTPUT_GRAMMAR_REQUIRED_EXTRA, RETURN_CODEC_EXTRA, RETURN_SHAPE_EXTRA, shape_to_grammar,
13    terminal_model_content,
14};
15use sim_lib_bridge::{
16    BridgeObligation, BridgeReport, FrontierMenu, effective_caps, frontier, rx_check,
17};
18use sim_shape::{ExprKind, ExprKindShape, FieldShape, FieldSpec};
19use sim_value::{access::field, build::entry};
20use std::sync::Arc;
21
22use crate::frame_propose::propose_frame;
23use crate::lift::{compiled_intent, report_summary, validate_candidate};
24use crate::normalize::normalize_prose;
25use crate::{CompiledIntent, LiftOptions};
26
27const FRONTIER_TARGET: &str = "model:forge-frontier";
28const MAX_FRONTIER_ATTEMPTS: usize = 16;
29
30/// Compiles prose into a checked candidate packet by authoring one BRIDGE part
31/// at a time through the shared frontier menu.
32pub fn forge_lift_frontier(
33    cx: &mut Cx,
34    target: &dyn EvalFabric,
35    prose: &str,
36    opts: &LiftOptions,
37) -> Result<CompiledIntent> {
38    let (normalized, source) = normalize_prose(prose)?;
39    let book = BridgeBook::standard();
40    let mut packet = seed_packet(&normalized, &source)?;
41    let mut row_index = 0usize;
42    let mut obligations = Vec::new();
43
44    for _ in 0..MAX_FRONTIER_ATTEMPTS {
45        if let Some(intent) = completed_intent(cx, &book, opts, source.clone(), &packet)? {
46            return Ok(intent);
47        }
48
49        let expected = expected_next_part(&packet)?;
50        let menu = frontier(cx, &packet)?;
51        let row = request_frontier_part(
52            cx,
53            target,
54            &packet,
55            &menu,
56            &expected,
57            row_index,
58            &obligations,
59        )?;
60        let report = validate_frontier_part(cx, &book, &packet, &menu, &expected, row_index, &row)?;
61        if !report.accepted() {
62            obligations = report.obligations;
63            continue;
64        }
65
66        packet.body.push(row.part);
67        packet.header.cid = None;
68        obligations.clear();
69        row_index += 1;
70    }
71
72    Err(Error::Eval(format!(
73        "forge frontier lift failed after {MAX_FRONTIER_ATTEMPTS} attempt(s): {}",
74        report_summary(&BridgeReport {
75            packet_cid: packet
76                .header
77                .cid
78                .clone()
79                .unwrap_or_else(|| "unstamped".to_owned()),
80            accepted_parts: Vec::new(),
81            rejected_parts: Vec::new(),
82            obligations,
83        })
84    )))
85}
86
87fn seed_packet(normalized: &str, source: &ContentId) -> Result<BridgePacket> {
88    Ok(BridgePacket {
89        header: BridgeHeader {
90            cid: None,
91            move_kind: Symbol::new("request"),
92            from: "sim".to_owned(),
93            to: vec![FRONTIER_TARGET.to_owned()],
94            role: Symbol::new("implementer"),
95            parents: Vec::new(),
96            task: Symbol::new("T1"),
97            output: Symbol::new("O1"),
98            ceiling: vec![Symbol::qualified("ai", "run")],
99            context: vec![Symbol::new("G1")],
100            provenance: BridgeProvenance::default(),
101        },
102        body: vec![BridgePart {
103            id: Symbol::new("G1"),
104            kind: Symbol::qualified("bridge", "Given"),
105            payload: Expr::Map(vec![
106                entry(
107                    "kind",
108                    Expr::Symbol(Symbol::qualified("forge", "ProseSource")),
109                ),
110                entry("content-id", Expr::String(content_id_string(source))),
111                entry("prose", Expr::String(normalized.to_owned())),
112            ]),
113        }],
114        warrant: None,
115    })
116}
117
118fn completed_intent(
119    cx: &mut Cx,
120    book: &BridgeBook,
121    opts: &LiftOptions,
122    source: ContentId,
123    packet: &BridgePacket,
124) -> Result<Option<CompiledIntent>> {
125    if !packet.body.iter().any(|part| part.id == packet.header.task)
126        || !packet
127            .body
128            .iter()
129            .any(|part| part.id == packet.header.output)
130    {
131        return Ok(None);
132    }
133    let stamped = stamp_packet_cid(packet)?;
134    let report = validate_candidate(cx, book, &stamped)?;
135    if report.accepted() {
136        cx.datum_store_mut()
137            .intern(canonical_packet_datum(&stamped))?;
138        Ok(Some(compiled_intent(opts, source, stamped)?))
139    } else {
140        Ok(None)
141    }
142}
143
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145enum ExpectedPart {
146    TaskFrame,
147    Return,
148}
149
150impl ExpectedPart {
151    fn id(self) -> Symbol {
152        match self {
153            Self::TaskFrame => Symbol::new("T1"),
154            Self::Return => Symbol::new("O1"),
155        }
156    }
157
158    fn kind(self) -> Symbol {
159        match self {
160            Self::TaskFrame => Symbol::qualified("bridge", "Frame"),
161            Self::Return => Symbol::qualified("bridge", "Return"),
162        }
163    }
164
165    fn label(self) -> &'static str {
166        match self {
167            Self::TaskFrame => "task Frame",
168            Self::Return => "Return",
169        }
170    }
171
172    fn to_expr(self) -> Expr {
173        Expr::Map(vec![
174            entry("id", Expr::Symbol(self.id())),
175            entry("kind", Expr::Symbol(self.kind())),
176            entry("label", Expr::String(self.label().to_owned())),
177        ])
178    }
179}
180
181fn expected_next_part(packet: &BridgePacket) -> Result<ExpectedPart> {
182    if !packet.body.iter().any(|part| part.id == packet.header.task) {
183        return Ok(ExpectedPart::TaskFrame);
184    }
185    if !packet
186        .body
187        .iter()
188        .any(|part| part.id == packet.header.output)
189    {
190        return Ok(ExpectedPart::Return);
191    }
192    Err(Error::Eval(
193        "forge frontier packet has task and return but is still incomplete".to_owned(),
194    ))
195}
196
197struct FrontierPartRow {
198    head: Symbol,
199    part: BridgePart,
200}
201
202fn request_frontier_part(
203    cx: &mut Cx,
204    target: &dyn EvalFabric,
205    packet: &BridgePacket,
206    menu: &FrontierMenu,
207    expected: &ExpectedPart,
208    row_index: usize,
209    obligations: &[BridgeObligation],
210) -> Result<FrontierPartRow> {
211    let request = frontier_part_request(cx, packet, menu, expected, row_index, obligations)?;
212    let reply = target.realize(cx, request)?;
213    decode_frontier_part_reply(cx, reply)
214}
215
216fn frontier_part_request(
217    cx: &mut Cx,
218    packet: &BridgePacket,
219    menu: &FrontierMenu,
220    expected: &ExpectedPart,
221    row_index: usize,
222    obligations: &[BridgeObligation],
223) -> Result<EvalRequest> {
224    let row_path = format!("frontier/rows/{row_index}");
225    let frame_proposal = if matches!(expected, ExpectedPart::TaskFrame) {
226        frame_proposal_expr(cx, packet)?
227    } else {
228        None
229    };
230    let mut body_fields = vec![
231        entry(
232            "mode",
233            Expr::Symbol(Symbol::qualified("forge", "frontier-part")),
234        ),
235        entry("row-path", Expr::String(row_path.clone())),
236        entry("expected-part", expected.to_expr()),
237        entry("head-menu", menu.heads.clone()),
238        entry("slot-menu", slot_menu_expr(menu)),
239        entry("partial-packet", packet_to_expr(packet)),
240        entry("obligations", obligations_expr(obligations)),
241    ];
242    if let Some(proposal) = &frame_proposal {
243        body_fields.push(entry("frame-proposal", proposal.clone()));
244    }
245
246    let mut model = ModelRequest::new(Expr::Map(body_fields), Vec::new());
247    model.extra.push(entry(
248        "forge-mode",
249        Expr::Symbol(Symbol::qualified("forge", "frontier-part")),
250    ));
251    model
252        .extra
253        .push(entry("forge-row-path", Expr::String(row_path)));
254    model
255        .extra
256        .push(entry("forge-expected-part", expected.to_expr()));
257    model
258        .extra
259        .push(entry("forge-frontier-heads", menu.heads.clone()));
260    model
261        .extra
262        .push(entry("forge-frontier-slots", slot_menu_expr(menu)));
263    model.extra.push(entry(
264        "forge-frontier-grammar",
265        Expr::String(menu.grammar.clone()),
266    ));
267    if !obligations.is_empty() {
268        model
269            .extra
270            .push(entry("forge-obligations", obligations_expr(obligations)));
271    }
272    if let Some(proposal) = frame_proposal {
273        model.extra.push(entry("forge-frame-proposal", proposal));
274    }
275    model.extra.push(entry(
276        RETURN_CODEC_EXTRA,
277        Expr::Symbol(Symbol::qualified("codec", "json")),
278    ));
279    model
280        .extra
281        .push(entry(RETURN_SHAPE_EXTRA, row_shape_expr(menu, expected)));
282    model.extra.push(entry(
283        OUTPUT_GRAMMAR_EXTRA,
284        Expr::String(row_grammar(menu)?),
285    ));
286    model.extra.push(entry(
287        OUTPUT_GRAMMAR_DIALECT_EXTRA,
288        Expr::Symbol(Symbol::new("json-schema")),
289    ));
290    model
291        .extra
292        .push(entry(OUTPUT_GRAMMAR_REQUIRED_EXTRA, Expr::Bool(true)));
293
294    Ok(EvalRequest {
295        expr: Expr::from(model),
296        result_shape: None,
297        required_capabilities: effective_caps(cx, packet)?.iter().cloned().collect(),
298        deadline: None,
299        consistency: Consistency::default(),
300        mode: EvalMode::default(),
301        answer_limit: None,
302        stream_buffer: None,
303        stream: false,
304        trace: false,
305    })
306}
307
308fn decode_frontier_part_reply(
309    cx: &mut Cx,
310    reply: sim_kernel::EvalReply,
311) -> Result<FrontierPartRow> {
312    let response = ModelResponse::try_from(reply.value.object().as_expr(cx)?)?;
313    let content = terminal_model_content(&response)?;
314    let expr = field(content, "row").unwrap_or(content);
315    let head = match field(expr, "head") {
316        Some(Expr::Symbol(symbol)) => symbol.clone(),
317        Some(Expr::String(text)) => Symbol::new(text.as_str()),
318        _ => {
319            return Err(Error::Eval(
320                "forge frontier row is missing symbolic head".to_owned(),
321            ));
322        }
323    };
324    let part = part_from_expr(
325        field(expr, "part")
326            .ok_or_else(|| Error::Eval("forge frontier row is missing part".to_owned()))?,
327    )?;
328    Ok(FrontierPartRow { head, part })
329}
330
331fn part_from_expr(expr: &Expr) -> Result<BridgePart> {
332    let id = match field(expr, "id") {
333        Some(Expr::Symbol(symbol)) => symbol.clone(),
334        _ => return Err(Error::Eval("forge frontier part is missing id".to_owned())),
335    };
336    let kind = match field(expr, "kind") {
337        Some(Expr::Symbol(symbol)) => symbol.clone(),
338        _ => {
339            return Err(Error::Eval(
340                "forge frontier part is missing kind".to_owned(),
341            ));
342        }
343    };
344    let payload = field(expr, "payload")
345        .ok_or_else(|| Error::Eval("forge frontier part is missing payload".to_owned()))?
346        .clone();
347    Ok(BridgePart { id, kind, payload })
348}
349
350fn validate_frontier_part(
351    cx: &mut Cx,
352    book: &BridgeBook,
353    packet: &BridgePacket,
354    menu: &FrontierMenu,
355    expected: &ExpectedPart,
356    row_index: usize,
357    row: &FrontierPartRow,
358) -> Result<BridgeReport> {
359    let mut report = BridgeReport::new(packet_report_cid(packet));
360    let head_choices = head_choices(menu);
361    if !head_choices.contains(&row.head) {
362        report.obligate(BridgeObligation::new(
363            format!("frontier/rows/{row_index}/head"),
364            "frontier row selected an off-menu head",
365            head_choices
366                .iter()
367                .map(Symbol::as_qualified_str)
368                .collect::<Vec<_>>()
369                .join(", "),
370            row.head.as_qualified_str(),
371            head_choices
372                .iter()
373                .map(Symbol::as_qualified_str)
374                .collect::<Vec<_>>(),
375        ));
376        return Ok(report);
377    }
378
379    if row.part.id != expected.id() || row.part.kind != expected.kind() {
380        report.obligate(BridgeObligation::new(
381            format!("frontier/rows/{row_index}/part"),
382            "frontier row supplied the wrong part slot",
383            format!("{} {}", expected.id(), expected.kind()),
384            format!("{} {}", row.part.id, row.part.kind),
385            vec![expected.label().to_owned()],
386        ));
387        return Ok(report);
388    }
389
390    if matches!(expected, ExpectedPart::TaskFrame) && data_degrade_frame(book, &row.part.payload) {
391        report.obligate(BridgeObligation::new(
392            format!("frontier/rows/{row_index}/part"),
393            "frontier row fell through to data because no frame matched",
394            "approved typed bridge/Frame or candidate FrameSpec proposal",
395            "prose data frame",
396            vec!["propose-frame".to_owned()],
397        ));
398        return Ok(report);
399    }
400
401    if matches!(expected, ExpectedPart::TaskFrame)
402        && let Err(err) = book.frames.validate_payload(&row.part.payload)
403    {
404        report.obligate(BridgeObligation::new(
405            format!("frontier/rows/{row_index}/part"),
406            "frontier row part did not satisfy the expected Shape",
407            "registered bridge/Frame payload",
408            err.to_string(),
409            vec![expected.label().to_owned()],
410        ));
411        return Ok(report);
412    }
413
414    let mut candidate = packet.clone();
415    candidate.body.push(row.part.clone());
416    candidate.header.cid = None;
417    let candidate_report = if matches!(expected, ExpectedPart::Return) {
418        validate_candidate(cx, book, &candidate)?
419    } else {
420        rx_check(cx, book, &candidate, None)?
421    };
422    let part_id = row.part.id.as_qualified_str();
423    let part_path = format!("body/{part_id}");
424    let part_obligations = candidate_report
425        .obligations
426        .iter()
427        .filter(|obligation| obligation.path.starts_with(&part_path))
428        .cloned()
429        .collect::<Vec<_>>();
430    if candidate_report.accepted_parts.contains(&part_id) && part_obligations.is_empty() {
431        report.accept(&row.part.id);
432    } else {
433        report.reject(&row.part.id);
434        if part_obligations.is_empty() {
435            report.obligate(BridgeObligation::new(
436                format!("frontier/rows/{row_index}/part"),
437                "frontier row part did not satisfy the expected Shape",
438                expected.label(),
439                format!("{} {}", row.part.id, row.part.kind),
440                vec![expected.label().to_owned()],
441            ));
442        } else {
443            for obligation in part_obligations {
444                report.obligate(row_obligation(row_index, obligation));
445            }
446        }
447    }
448    Ok(report)
449}
450
451fn packet_report_cid(packet: &BridgePacket) -> String {
452    packet
453        .header
454        .cid
455        .clone()
456        .unwrap_or_else(|| match packet_content_id(packet) {
457            Ok(id) => content_id_string(&id),
458            Err(_) => "unhashable".to_owned(),
459        })
460}
461
462fn row_obligation(row_index: usize, obligation: BridgeObligation) -> BridgeObligation {
463    BridgeObligation::new(
464        format!("frontier/rows/{row_index}/part"),
465        obligation.reason,
466        obligation.expected,
467        obligation.actual,
468        obligation.repair_menu,
469    )
470}
471
472fn head_choices(menu: &FrontierMenu) -> Vec<Symbol> {
473    match field(&menu.heads, "choices") {
474        Some(Expr::Vector(items)) | Some(Expr::List(items)) => items
475            .iter()
476            .filter_map(|item| match item {
477                Expr::Symbol(symbol) => Some(symbol.clone()),
478                _ => None,
479            })
480            .collect(),
481        _ => Vec::new(),
482    }
483}
484
485fn data_degrade_frame(book: &BridgeBook, payload: &Expr) -> bool {
486    let Ok(payload) = BridgeFramePayload::from_expr(payload) else {
487        return false;
488    };
489    let Ok(spec) = book.frames.require_spec(&payload.frame) else {
490        return false;
491    };
492    spec.holes
493        .iter()
494        .any(|hole| matches!(hole.kind, FrameHoleKind::Prose))
495}
496
497fn frame_proposal_expr(cx: &mut Cx, packet: &BridgePacket) -> Result<Option<Expr>> {
498    let Some(prose) = packet_source_prose(packet) else {
499        return Ok(None);
500    };
501    Ok(Some(propose_frame(cx, prose)?.to_expr()))
502}
503
504fn packet_source_prose(packet: &BridgePacket) -> Option<&str> {
505    packet.body.iter().find_map(|part| {
506        if part.id != Symbol::new("G1") {
507            return None;
508        }
509        match field(&part.payload, "prose") {
510            Some(Expr::String(prose)) => Some(prose.as_str()),
511            _ => None,
512        }
513    })
514}
515
516fn row_shape_expr(menu: &FrontierMenu, expected: &ExpectedPart) -> Expr {
517    Expr::Map(vec![
518        entry(
519            "shape",
520            Expr::Symbol(Symbol::qualified("forge", "FrontierPart")),
521        ),
522        entry("head", menu.heads.clone()),
523        entry("expected-part", expected.to_expr()),
524    ])
525}
526
527fn row_grammar(menu: &FrontierMenu) -> Result<String> {
528    let shape = FieldShape::anonymous(vec![
529        FieldSpec::required(Symbol::new("head"), menu.head_shape.clone()),
530        FieldSpec::required(
531            Symbol::new("part"),
532            Arc::new(ExprKindShape::new(ExprKind::Map)),
533        ),
534    ]);
535    shape_to_grammar(&shape)
536}
537
538fn slot_menu_expr(menu: &FrontierMenu) -> Expr {
539    Expr::Vector(
540        menu.slots
541            .iter()
542            .map(|(slot, shape)| {
543                Expr::Map(vec![
544                    entry("slot", Expr::String(slot.clone())),
545                    entry("shape", shape.clone()),
546                ])
547            })
548            .collect(),
549    )
550}
551
552fn obligations_expr(obligations: &[BridgeObligation]) -> Expr {
553    Expr::Vector(obligations.iter().map(BridgeObligation::to_expr).collect())
554}