1#![warn(missing_docs)]
4
5use proc_macro::TokenStream;
11use quote::quote;
12use syn::{Data, DeriveInput, Expr, Fields, Lit, parse_macro_input};
13
14#[proc_macro_derive(SomaFilter, attributes(soma))]
31pub fn derive_soma_filter(input: TokenStream) -> TokenStream {
32 let input = parse_macro_input!(input as DeriveInput);
33 derive_soma_filter_impl(input)
36 .unwrap_or_else(syn::Error::into_compile_error)
37 .into()
38}
39
40fn derive_soma_filter_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
41 let name = &input.ident;
42 let name_str = name.to_string();
43
44 let fields = match &input.data {
45 Data::Struct(data) => match &data.fields {
46 Fields::Named(fields) => &fields.named,
47 other => {
48 return Err(syn::Error::new_spanned(
49 other,
50 "SomaFilter needs named fields: it hashes each one by name",
51 ));
52 }
53 },
54 _ => {
55 return Err(syn::Error::new_spanned(
56 &input.ident,
57 "SomaFilter can only be derived for a struct",
58 ));
59 }
60 };
61
62 let struct_attrs = parse_struct_attrs(&input.attrs)?;
64
65 let mut hash_parts = Vec::new();
67 let mut search_dims = Vec::new();
68 let mut from_sample_fields = Vec::new();
69 let mut current_params_fields = Vec::new();
70
71 for field in fields {
72 let field_name = field.ident.as_ref().unwrap();
73 let field_name_str = field_name.to_string();
74 let field_ty = &field.ty;
75 let field_attrs = parse_field_attrs(&field.attrs)?;
76
77 if !field_attrs.skip_hash {
82 hash_parts.push(quote! {
83 {
84 let serialized = somatize_core::canon::canonical_bytes(&self.#field_name)
85 .unwrap_or_else(|e| panic!(
86 "field `{}` of `{}` is not canonically hashable ({}); \
87 annotate it with #[soma(skip_hash)]",
88 #field_name_str, #name_str, e
89 ));
90 parts.push(serialized);
91 }
92 });
93 }
94
95 if let Some(search) = &field_attrs.search {
97 let dim = generate_search_dimension(&field_name_str, field_ty, search);
98 search_dims.push(dim);
99
100 from_sample_fields.push(generate_from_sample(field_name, &field_name_str, field_ty));
102
103 current_params_fields.push(quote! {
105 params.insert(
106 #field_name_str.to_string(),
107 serde_json::to_value(&self.#field_name).unwrap_or_default(),
108 );
109 });
110 } else {
111 from_sample_fields.push(quote! {
113 #field_name: Default::default(),
114 });
115 }
116 }
117
118 let kind = match struct_attrs.kind.as_deref() {
120 Some("Stateless") => quote! { somatize_core::filter::FilterKind::Stateless },
121 Some("Opaque") => quote! { somatize_core::filter::FilterKind::Opaque },
122 _ => quote! { somatize_core::filter::FilterKind::Trainable },
123 };
124
125 let cacheable = struct_attrs.cacheable;
126 let differentiable = struct_attrs.differentiable;
127 let deterministic = struct_attrs.deterministic;
128
129 let cache_version_part = match &struct_attrs.cache_version {
133 Some(v) => quote! { parts.push(#v.as_bytes().to_vec()); },
134 None => quote! {},
135 };
136
137 let stream_mode = match struct_attrs.stream.as_deref() {
138 Some("Barrier") => quote! { somatize_core::filter::StreamMode::Barrier },
139 Some("Evolving") => quote! { somatize_core::filter::StreamMode::Evolving },
140 _ => quote! { somatize_core::filter::StreamMode::FixedState },
141 };
142
143 let expanded = quote! {
144 impl #name {
145 pub fn config_hash(&self) -> somatize_core::cache::CacheKey {
147 let mut parts: Vec<Vec<u8>> = Vec::new();
148 parts.push(#name_str.as_bytes().to_vec());
150 #cache_version_part
151 #(#hash_parts)*
153 let refs: Vec<&[u8]> = parts.iter().map(|p| p.as_slice()).collect();
154 somatize_core::cache::CacheKey::from_parts(&refs)
155 }
156
157 pub fn soma_meta(&self) -> somatize_core::filter::FilterMeta {
159 somatize_core::filter::FilterMeta {
160 name: #name_str.to_string(),
161 kind: #kind,
162 cacheable: #cacheable,
163 differentiable: #differentiable,
164 deterministic: #deterministic,
165 stream_mode: #stream_mode,
166 distribution: somatize_core::filter::Distribution::Local,
167 input_schema: None,
168 output_schema: None,
169 }
170 }
171 }
172
173 impl somatize_core::search::Searchable for #name {
174 fn search_space() -> somatize_core::search::SearchSpace {
175 let mut space = somatize_core::search::SearchSpace::new();
176 #(#search_dims)*
177 space
178 }
179
180 fn from_sample(
181 params: &std::collections::HashMap<String, serde_json::Value>,
182 ) -> somatize_core::error::Result<Self> {
183 Ok(Self {
184 #(#from_sample_fields)*
185 })
186 }
187
188 fn current_params(&self) -> std::collections::HashMap<String, serde_json::Value> {
189 let mut params = std::collections::HashMap::new();
190 #(#current_params_fields)*
191 params
192 }
193 }
194 };
195
196 Ok(expanded)
197}
198
199struct StructAttrs {
202 kind: Option<String>,
203 cacheable: bool,
204 differentiable: bool,
205 stream: Option<String>,
206 cache_version: Option<String>,
207 deterministic: bool,
208}
209
210struct FieldAttrs {
211 skip_hash: bool,
212 search: Option<SearchAttrs>,
213}
214
215struct SearchAttrs {
216 low: Option<f64>,
217 high: Option<f64>,
218 scale: Option<String>,
219 choices: Vec<String>,
220 auto: bool, }
222
223fn parse_struct_attrs(attrs: &[syn::Attribute]) -> syn::Result<StructAttrs> {
232 let mut result = StructAttrs {
233 kind: None,
234 cacheable: true,
235 differentiable: false,
236 stream: None,
237 cache_version: None,
238 deterministic: true,
239 };
240
241 for attr in attrs {
242 if !attr.path().is_ident("soma") {
243 continue;
244 }
245 attr.parse_nested_meta(|meta| {
246 if meta.path.is_ident("kind") {
247 let value = meta.value()?;
248 let lit: Lit = value.parse()?;
249 if let Lit::Str(s) = lit {
250 result.kind = Some(s.value());
251 }
252 } else if meta.path.is_ident("cacheable") {
253 if let Ok(value) = meta.value() {
254 let lit: Lit = value.parse()?;
255 if let Lit::Bool(b) = lit {
256 result.cacheable = b.value;
257 }
258 }
259 } else if meta.path.is_ident("differentiable") {
261 if let Ok(value) = meta.value() {
262 let lit: Lit = value.parse()?;
263 if let Lit::Bool(b) = lit {
264 result.differentiable = b.value;
265 }
266 }
267 } else if meta.path.is_ident("stream") {
268 let value = meta.value()?;
269 let lit: Lit = value.parse()?;
270 if let Lit::Str(s) = lit {
271 result.stream = Some(s.value());
272 }
273 } else if meta.path.is_ident("cache_version") {
274 let value = meta.value()?;
275 let lit: Lit = value.parse()?;
276 if let Lit::Str(s) = lit {
277 result.cache_version = Some(s.value());
278 }
279 } else if meta.path.is_ident("deterministic") {
280 if let Ok(value) = meta.value() {
282 let lit: Lit = value.parse()?;
283 if let Lit::Bool(b) = lit {
284 result.deterministic = b.value;
285 }
286 }
287 } else {
288 return Err(meta.error(
289 "unknown `soma` attribute; expected one of: kind, cacheable, \
290 differentiable, stream, cache_version, deterministic",
291 ));
292 }
293 Ok(())
294 })?;
295 }
296
297 Ok(result)
298}
299
300fn parse_field_attrs(attrs: &[syn::Attribute]) -> syn::Result<FieldAttrs> {
302 let mut result = FieldAttrs {
303 skip_hash: false,
304 search: None,
305 };
306
307 for attr in attrs {
308 if !attr.path().is_ident("soma") {
309 continue;
310 }
311 attr.parse_nested_meta(|meta| {
312 if meta.path.is_ident("skip_hash") {
313 result.skip_hash = true;
314 } else if meta.path.is_ident("search") {
315 let mut search = SearchAttrs {
316 low: None,
317 high: None,
318 scale: None,
319 choices: Vec::new(),
320 auto: false,
321 };
322
323 if meta.input.peek(syn::token::Paren) {
324 meta.parse_nested_meta(|inner| {
325 if inner.path.is_ident("low") {
326 let value = inner.value()?;
327 let expr: Expr = value.parse()?;
328 search.low = Some(expr_to_f64(&expr)?);
329 } else if inner.path.is_ident("high") {
330 let value = inner.value()?;
331 let expr: Expr = value.parse()?;
332 search.high = Some(expr_to_f64(&expr)?);
333 } else if inner.path.is_ident("scale") {
334 let value = inner.value()?;
335 let lit: Lit = value.parse()?;
336 if let Lit::Str(s) = lit {
337 search.scale = Some(s.value());
338 }
339 } else if inner.path.is_ident("choices") {
340 let value = inner.value()?;
341 let array: syn::ExprArray = value.parse()?;
342 for elem in &array.elems {
343 if let Expr::Lit(lit) = elem {
344 match &lit.lit {
345 Lit::Str(s) => search.choices.push(s.value()),
346 Lit::Int(i) => search.choices.push(i.to_string()),
347 Lit::Float(f) => search.choices.push(f.to_string()),
348 Lit::Bool(b) => search.choices.push(b.value.to_string()),
349 other => {
350 return Err(syn::Error::new_spanned(
351 other,
352 "search choices must be string, integer, \
353 float or bool literals",
354 ));
355 }
356 }
357 }
358 }
359 } else {
360 return Err(inner.error(
361 "unknown `search` argument; expected one of: \
362 low, high, scale, choices",
363 ));
364 }
365 Ok(())
366 })?;
367 } else {
368 search.auto = true;
369 }
370
371 result.search = Some(search);
372 } else {
373 return Err(meta.error(
374 "unknown `soma` attribute on a field; expected `skip_hash` or `search`",
375 ));
376 }
377 Ok(())
378 })?;
379 }
380
381 Ok(result)
382}
383
384fn expr_to_f64(expr: &Expr) -> syn::Result<f64> {
385 match expr {
386 Expr::Lit(lit) => match &lit.lit {
387 Lit::Float(f) => f.base10_parse(),
388 Lit::Int(i) => Ok(i.base10_parse::<i64>()? as f64),
389 other => Err(syn::Error::new_spanned(other, "expected a numeric literal")),
390 },
391 Expr::Unary(unary) if matches!(unary.op, syn::UnOp::Neg(_)) => {
392 Ok(-expr_to_f64(&unary.expr)?)
393 }
394 other => Err(syn::Error::new_spanned(
395 other,
396 "expected a numeric literal (optionally negated)",
397 )),
398 }
399}
400
401fn generate_search_dimension(
404 name: &str,
405 ty: &syn::Type,
406 search: &SearchAttrs,
407) -> proc_macro2::TokenStream {
408 let type_str = quote!(#ty).to_string();
409
410 if !search.choices.is_empty() {
412 let choices = &search.choices;
413 return quote! {
414 space.add(somatize_core::search::SearchDimension::Categorical {
415 name: #name.to_string(),
416 choices: vec![#(serde_json::json!(#choices)),*],
417 });
418 };
419 }
420
421 if search.auto && type_str == "bool" {
423 return quote! {
424 space.add(somatize_core::search::SearchDimension::Categorical {
425 name: #name.to_string(),
426 choices: vec![serde_json::json!(true), serde_json::json!(false)],
427 });
428 };
429 }
430
431 if let (Some(low), Some(high)) = (search.low, search.high) {
433 let scale = match search.scale.as_deref() {
434 Some("log") => quote! { somatize_core::search::Scale::Log },
435 Some("reverse_log") => quote! { somatize_core::search::Scale::ReverseLog },
436 _ => quote! { somatize_core::search::Scale::Linear },
437 };
438
439 let is_int = type_str.contains("usize")
440 || type_str.contains("i32")
441 || type_str.contains("i64")
442 || type_str.contains("u32")
443 || type_str.contains("u64");
444
445 if is_int {
446 let low_i = low as i64;
447 let high_i = high as i64;
448 return quote! {
449 space.add(somatize_core::search::SearchDimension::Int {
450 name: #name.to_string(),
451 low: #low_i,
452 high: #high_i,
453 scale: #scale,
454 });
455 };
456 } else {
457 return quote! {
458 space.add(somatize_core::search::SearchDimension::Float {
459 name: #name.to_string(),
460 low: #low,
461 high: #high,
462 scale: #scale,
463 default: None,
464 });
465 };
466 }
467 }
468
469 quote! {}
471}
472
473fn generate_from_sample(
474 field_name: &syn::Ident,
475 field_name_str: &str,
476 field_ty: &syn::Type,
477) -> proc_macro2::TokenStream {
478 let type_str = quote!(#field_ty).to_string();
479
480 if type_str.contains("String") {
481 quote! {
482 #field_name: params.get(#field_name_str)
483 .and_then(|v| v.as_str())
484 .unwrap_or_default()
485 .to_string(),
486 }
487 } else if type_str == "bool" {
488 quote! {
489 #field_name: params.get(#field_name_str)
490 .and_then(|v| v.as_bool())
491 .unwrap_or_default(),
492 }
493 } else if type_str.contains("usize") {
494 quote! {
495 #field_name: params.get(#field_name_str)
496 .and_then(|v| v.as_u64())
497 .unwrap_or_default() as usize,
498 }
499 } else if type_str.contains("i64") || type_str.contains("i32") {
500 quote! {
501 #field_name: params.get(#field_name_str)
502 .and_then(|v| v.as_i64())
503 .unwrap_or_default() as #field_ty,
504 }
505 } else {
506 quote! {
508 #field_name: params.get(#field_name_str)
509 .and_then(|v| v.as_f64())
510 .unwrap_or_default() as #field_ty,
511 }
512 }
513}
514
515#[proc_macro_derive(SomaStep, attributes(soma))]
534pub fn derive_soma_step(input: TokenStream) -> TokenStream {
535 let input = parse_macro_input!(input as DeriveInput);
536 derive_soma_step_impl(input)
539 .unwrap_or_else(syn::Error::into_compile_error)
540 .into()
541}
542
543fn derive_soma_step_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
544 let name = &input.ident;
545 let name_str = name.to_string();
546
547 let fields = match &input.data {
548 Data::Struct(data) => match &data.fields {
549 Fields::Named(fields) => &fields.named,
550 other => {
551 return Err(syn::Error::new_spanned(
552 other,
553 "SomaStep needs named fields: it hashes each one by name",
554 ));
555 }
556 },
557 _ => {
558 return Err(syn::Error::new_spanned(
559 &input.ident,
560 "SomaStep can only be derived for a struct",
561 ));
562 }
563 };
564
565 let struct_attrs = parse_struct_attrs(&input.attrs)?;
566 let cache_version_part = match &struct_attrs.cache_version {
567 Some(v) => quote! { parts.push(#v.as_bytes().to_vec()); },
568 None => quote! {},
569 };
570
571 let hash_parts: Vec<_> = fields
572 .iter()
573 .filter(|f| parse_field_attrs(&f.attrs).is_ok_and(|a| !a.skip_hash))
574 .map(|f| {
575 let field_name = f.ident.as_ref().unwrap();
576 let field_name_str = field_name.to_string();
577 quote! {
578 {
579 let serialized = somatize_core::canon::canonical_bytes(&self.#field_name)
580 .unwrap_or_else(|e| panic!(
581 "field `{}` of `{}` is not canonically hashable ({}); \
582 annotate it with #[soma(skip_hash)]",
583 #field_name_str, #name_str, e
584 ));
585 parts.push(serialized);
586 }
587 }
588 })
589 .collect();
590
591 Ok(quote! {
592 impl #name {
593 pub fn config_hash(&self) -> somatize_core::cache::CacheKey {
598 let mut parts: Vec<Vec<u8>> = Vec::new();
599 parts.push(#name_str.as_bytes().to_vec());
600 #cache_version_part
601 #(#hash_parts)*
602 let refs: Vec<&[u8]> = parts.iter().map(|p| p.as_slice()).collect();
603 somatize_core::cache::CacheKey::from_parts(&refs)
604 }
605 }
606 })
607}