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#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct FrameSpecProposal {
14 pub id: Symbol,
16 pub kind: FrameKind,
18 pub template: String,
20 pub holes: Vec<FrameHoleSpec>,
22 pub rationale: String,
24}
25
26impl FrameSpecProposal {
27 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 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 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
78pub 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
107pub 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
116pub 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}