Skip to main content

sim_lib_forge/
frame_propose.rs

1use std::collections::BTreeMap;
2use std::sync::{Mutex, OnceLock};
3
4use sim_codec_bridge::{
5    BridgeFrameBook, BridgeFramePayload, BridgePart, FrameHoleKind, FrameHoleSpec, FrameKind,
6    FrameSpec, frame_book_content_id,
7};
8use sim_kernel::{ContentId, Cx, Datum, Error, Expr, Result, Symbol};
9use sim_value::build::entry;
10
11use crate::semantic_tokens;
12
13/// Candidate frame specification inferred from prose that has no registered frame.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct FrameSpecProposal {
16    /// Proposed frame id.
17    pub id: Symbol,
18    /// Proposed illocutionary frame kind.
19    pub kind: FrameKind,
20    /// Proposed deterministic template body using `{hole}` placeholders.
21    pub template: String,
22    /// Proposed typed holes accepted by the template.
23    pub holes: Vec<FrameHoleSpec>,
24    /// Short explanation of why this frame fits the prose fragment.
25    pub rationale: String,
26}
27
28impl FrameSpecProposal {
29    /// Returns the proposed hole names as stable strings.
30    pub fn hole_names(&self) -> Vec<String> {
31        self.holes
32            .iter()
33            .map(|hole| hole.name.as_qualified_str().to_owned())
34            .collect()
35    }
36
37    /// Encodes the proposal as data for model repair prompts and review views.
38    pub fn to_expr(&self) -> Expr {
39        Expr::Map(vec![
40            entry("id", Expr::Symbol(self.id.clone())),
41            entry("kind", Expr::Symbol(frame_kind_symbol(self.kind))),
42            entry("template", Expr::String(self.template.clone())),
43            entry(
44                "holes",
45                Expr::Vector(
46                    self.holes
47                        .iter()
48                        .map(|hole| {
49                            Expr::Map(vec![
50                                entry("name", Expr::Symbol(hole.name.clone())),
51                                entry("kind", Expr::Symbol(hole_kind_symbol(hole.kind))),
52                            ])
53                        })
54                        .collect(),
55                ),
56            ),
57            entry("rationale", Expr::String(self.rationale.clone())),
58            entry(
59                "status",
60                Expr::Symbol(Symbol::qualified("forge", "candidate")),
61            ),
62        ])
63    }
64
65    /// Computes the content id of this candidate proposal.
66    pub fn content_id(&self) -> Result<ContentId> {
67        proposal_datum(self).content_id()
68    }
69
70    fn frame_spec(&self) -> FrameSpec {
71        FrameSpec::new(
72            self.id.clone(),
73            self.kind,
74            intern_template(&self.template),
75            self.holes.clone(),
76        )
77    }
78}
79
80/// Proposes a typed frame spec for prose that has no registered frame.
81pub fn propose_frame(_cx: &mut Cx, prose_fragment: &str) -> Result<FrameSpecProposal> {
82    let prose = prose_fragment.trim();
83    if prose.is_empty() {
84        return Err(Error::Eval(
85            "forge frame proposal requires non-empty prose".to_owned(),
86        ));
87    }
88    let tokens = semantic_tokens(prose);
89    if tokens.is_empty() {
90        return Err(Error::Eval(
91            "forge frame proposal requires at least one token".to_owned(),
92        ));
93    }
94    let kind = infer_kind(prose);
95    let (template, holes) = proposal_template(kind, &tokens);
96    let id = Symbol::qualified("forge", frame_slug(kind, &tokens).as_str());
97    Ok(FrameSpecProposal {
98        id,
99        kind,
100        template,
101        holes,
102        rationale: format!(
103            "The prose is mapped to a typed {} frame instead of prose data.",
104            frame_kind_label(kind)
105        ),
106    })
107}
108
109/// Registers a human-approved proposal in `book` and returns the new book id.
110pub fn approve_frame_proposal(
111    book: &mut BridgeFrameBook,
112    proposal: &FrameSpecProposal,
113) -> Result<ContentId> {
114    book.register(proposal.frame_spec());
115    frame_book_content_id(book)
116}
117
118/// Builds a normative frame part from an approved proposal and matching prose.
119pub fn proposed_frame_part(
120    book: &BridgeFrameBook,
121    proposal: &FrameSpecProposal,
122    prose_fragment: &str,
123    id: Symbol,
124) -> Result<BridgePart> {
125    book.require_spec(&proposal.id)?;
126    let payload = proposal_payload(proposal, prose_fragment)?;
127    book.validate_payload(&payload.to_expr())?;
128    Ok(BridgePart {
129        id,
130        kind: Symbol::qualified("bridge", "Frame"),
131        payload: payload.to_expr(),
132    })
133}
134
135fn proposal_payload(
136    proposal: &FrameSpecProposal,
137    prose_fragment: &str,
138) -> Result<BridgeFramePayload> {
139    let tokens = semantic_tokens(prose_fragment);
140    if tokens.is_empty() {
141        return Err(Error::Eval(
142            "forge frame proposal cannot fill slots from empty prose".to_owned(),
143        ));
144    }
145    let mut payload = BridgeFramePayload::new(proposal.id.clone());
146    for hole in &proposal.holes {
147        payload = payload.with_slot(hole.name.clone(), slot_value(hole, &tokens, prose_fragment));
148    }
149    Ok(payload)
150}
151
152fn proposal_template(kind: FrameKind, tokens: &[String]) -> (String, Vec<FrameHoleSpec>) {
153    match kind {
154        FrameKind::Use => (
155            "{resource}.".to_owned(),
156            vec![FrameHoleSpec::new(
157                Symbol::new("resource"),
158                FrameHoleKind::Ref,
159            )],
160        ),
161        FrameKind::Inform => (
162            "{fact}.".to_owned(),
163            vec![FrameHoleSpec::new(Symbol::new("fact"), FrameHoleKind::Term)],
164        ),
165        FrameKind::Require | FrameKind::Forbid => (
166            "{rule}.".to_owned(),
167            vec![FrameHoleSpec::new(Symbol::new("rule"), FrameHoleKind::Term)],
168        ),
169        FrameKind::Prefer => (
170            "{choice}.".to_owned(),
171            vec![FrameHoleSpec::new(
172                Symbol::new("choice"),
173                FrameHoleKind::Choice,
174            )],
175        ),
176        FrameKind::Return => (
177            "{shape}.".to_owned(),
178            vec![FrameHoleSpec::new(
179                Symbol::new("shape"),
180                FrameHoleKind::Term,
181            )],
182        ),
183        FrameKind::Check => (
184            "{path}.".to_owned(),
185            vec![FrameHoleSpec::new(Symbol::new("path"), FrameHoleKind::Path)],
186        ),
187        FrameKind::Task => {
188            let target = tokens.get(1).or_else(|| tokens.first());
189            let holes = if target.is_some() {
190                vec![
191                    FrameHoleSpec::new(Symbol::new("action"), FrameHoleKind::Term),
192                    FrameHoleSpec::new(Symbol::new("target"), FrameHoleKind::Term),
193                ]
194            } else {
195                vec![FrameHoleSpec::new(
196                    Symbol::new("action"),
197                    FrameHoleKind::Term,
198                )]
199            };
200            let template = if holes.len() == 2 {
201                "{action} {target}.".to_owned()
202            } else {
203                "{action}.".to_owned()
204            };
205            (template, holes)
206        }
207    }
208}
209
210fn slot_value(hole: &FrameHoleSpec, tokens: &[String], prose_fragment: &str) -> Expr {
211    match hole.kind {
212        FrameHoleKind::Path => Expr::Vector(
213            tokens
214                .iter()
215                .map(|token| Expr::String(token.clone()))
216                .collect(),
217        ),
218        FrameHoleKind::Prose => Expr::String(prose_fragment.trim().to_owned()),
219        FrameHoleKind::Ref
220        | FrameHoleKind::Term
221        | FrameHoleKind::Choice
222        | FrameHoleKind::Number => {
223            let token = match hole.name.name.as_ref() {
224                "target" => tokens.last(),
225                "resource" | "fact" | "rule" | "choice" | "shape" => tokens.last(),
226                _ => tokens.first(),
227            }
228            .expect("proposal slots require at least one token");
229            Expr::Symbol(Symbol::new(token.as_str()))
230        }
231    }
232}
233
234fn infer_kind(prose: &str) -> FrameKind {
235    let lower = prose.trim().to_ascii_lowercase();
236    if lower.starts_with("use ") {
237        FrameKind::Use
238    } else if lower.starts_with("return ") {
239        FrameKind::Return
240    } else if lower.starts_with("check ") || lower.starts_with("verify ") {
241        FrameKind::Check
242    } else if lower.starts_with("prefer ") {
243        FrameKind::Prefer
244    } else if lower.contains("must not") || lower.contains("never ") || lower.starts_with("do not ")
245    {
246        FrameKind::Forbid
247    } else if lower.starts_with("require ") || lower.contains(" must ") {
248        FrameKind::Require
249    } else if lower.starts_with("note ") || lower.starts_with("inform ") {
250        FrameKind::Inform
251    } else {
252        FrameKind::Task
253    }
254}
255
256fn frame_slug(kind: FrameKind, tokens: &[String]) -> String {
257    let mut parts = vec!["frame".to_owned(), frame_kind_label(kind).to_owned()];
258    parts.extend(tokens.iter().take(3).cloned());
259    parts.join("-")
260}
261
262fn proposal_datum(proposal: &FrameSpecProposal) -> Datum {
263    Datum::Node {
264        tag: Symbol::qualified("forge", "FrameSpecProposal"),
265        fields: vec![
266            (Symbol::new("id"), Datum::Symbol(proposal.id.clone())),
267            (
268                Symbol::new("kind"),
269                Datum::Symbol(frame_kind_symbol(proposal.kind)),
270            ),
271            (
272                Symbol::new("template"),
273                Datum::String(proposal.template.clone()),
274            ),
275            (
276                Symbol::new("holes"),
277                Datum::Vector(
278                    proposal
279                        .holes
280                        .iter()
281                        .map(|hole| Datum::Node {
282                            tag: Symbol::qualified("forge", "FrameHoleProposal"),
283                            fields: vec![
284                                (Symbol::new("name"), Datum::Symbol(hole.name.clone())),
285                                (
286                                    Symbol::new("kind"),
287                                    Datum::Symbol(hole_kind_symbol(hole.kind)),
288                                ),
289                            ],
290                        })
291                        .collect(),
292                ),
293            ),
294            (
295                Symbol::new("rationale"),
296                Datum::String(proposal.rationale.clone()),
297            ),
298        ],
299    }
300}
301
302fn frame_kind_label(kind: FrameKind) -> &'static str {
303    match kind {
304        FrameKind::Use => "use",
305        FrameKind::Inform => "inform",
306        FrameKind::Task => "task",
307        FrameKind::Require => "require",
308        FrameKind::Forbid => "forbid",
309        FrameKind::Prefer => "prefer",
310        FrameKind::Return => "return",
311        FrameKind::Check => "check",
312    }
313}
314
315fn frame_kind_symbol(kind: FrameKind) -> Symbol {
316    let name = match kind {
317        FrameKind::Use => "Use",
318        FrameKind::Inform => "Inform",
319        FrameKind::Task => "Task",
320        FrameKind::Require => "Require",
321        FrameKind::Forbid => "Forbid",
322        FrameKind::Prefer => "Prefer",
323        FrameKind::Return => "Return",
324        FrameKind::Check => "Check",
325    };
326    Symbol::qualified("bridge", name)
327}
328
329fn hole_kind_symbol(kind: FrameHoleKind) -> Symbol {
330    let name = match kind {
331        FrameHoleKind::Ref => "Ref",
332        FrameHoleKind::Term => "Term",
333        FrameHoleKind::Choice => "Choice",
334        FrameHoleKind::Path => "Path",
335        FrameHoleKind::Number => "Number",
336        FrameHoleKind::Prose => "Prose",
337    };
338    Symbol::qualified("bridge", name)
339}
340
341fn intern_template(template: &str) -> &'static str {
342    static TEMPLATES: OnceLock<Mutex<BTreeMap<String, &'static str>>> = OnceLock::new();
343    let templates = TEMPLATES.get_or_init(|| Mutex::new(BTreeMap::new()));
344    let mut templates = templates.lock().expect("template interner mutex poisoned");
345    if let Some(existing) = templates.get(template) {
346        return existing;
347    }
348    let leaked = Box::leak(template.to_owned().into_boxed_str());
349    templates.insert(template.to_owned(), leaked);
350    leaked
351}