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