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#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct FrameSpecProposal {
16 pub id: Symbol,
18 pub kind: FrameKind,
20 pub template: String,
22 pub holes: Vec<FrameHoleSpec>,
24 pub rationale: String,
26}
27
28impl FrameSpecProposal {
29 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 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 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
80pub 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
109pub 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
118pub 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}