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