Skip to main content

somatize_macros/
lib.rs

1// The crate is fully documented and clippy runs with -D warnings in CI,
2// so this makes "public API without docs" a build error from here on.
3#![warn(missing_docs)]
4
5//! Proc macros for Soma filters: `#[derive(SomaFilter)]` (config hash,
6//! metadata, `Searchable`) and `#[derive(SomaStep)]` (a step's journal
7//! identity). The attributes each derive understands are documented on
8//! the derive itself.
9
10use proc_macro::TokenStream;
11use quote::quote;
12use syn::{Data, DeriveInput, Expr, Fields, Lit, parse_macro_input};
13
14/// Derive macro for generating `config_hash()` and `Searchable` implementations.
15///
16/// # Attributes
17///
18/// ## Struct-level
19/// - `#[soma(kind = "Trainable")]` - FilterKind (Stateless, Trainable, Opaque)
20/// - `#[soma(cacheable)]` or `#[soma(cacheable = false)]`
21/// - `#[soma(differentiable)]` or `#[soma(differentiable = false)]`
22/// - `#[soma(stream = "FixedState")]` - StreamMode
23///
24/// ## Field-level
25/// - `#[soma(search(low = 0.1, high = 10.0))]` - Float search range
26/// - `#[soma(search(low = 0.1, high = 10.0, scale = "log"))]` - With scale
27/// - `#[soma(search(choices = ["a", "b", "c"]))]` - Categorical
28/// - `#[soma(search)]` - Auto-detect from type (bool → Categorical)
29/// - `#[soma(skip_hash)]` - Exclude from config_hash
30#[proc_macro_derive(SomaFilter, attributes(soma))]
31pub fn derive_soma_filter(input: TokenStream) -> TokenStream {
32    let input = parse_macro_input!(input as DeriveInput);
33    // A malformed input is a compile error pointing at the offending
34    // token, not a `proc macro panicked` pointing at the derive.
35    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    // Parse struct-level attributes
63    let struct_attrs = parse_struct_attrs(&input.attrs)?;
64
65    // Separate fields into hash fields and search fields
66    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        // config_hash: include unless skip_hash. Canonical CBOR, never
78        // raw serializer output (HashMap iteration order is random) and
79        // never a silent empty fallback (two unserializable configs
80        // would collide).
81        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        // search: generate SearchDimension if annotated
96        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: extract from params map
101            from_sample_fields.push(generate_from_sample(field_name, &field_name_str, field_ty));
102
103            // current_params: insert into map
104            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            // Non-searchable: use Default
112            from_sample_fields.push(quote! {
113                #field_name: Default::default(),
114            });
115        }
116    }
117
118    // FilterKind
119    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    // Explicit version bump: folded into the hash so users can force
130    // invalidation when code changes the macro cannot see (helper fns,
131    // external resources).
132    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            /// Compute a content-addressable hash of this filter's configuration.
146            pub fn config_hash(&self) -> somatize_core::cache::CacheKey {
147                let mut parts: Vec<Vec<u8>> = Vec::new();
148                // Include type name
149                parts.push(#name_str.as_bytes().to_vec());
150                #cache_version_part
151                // Include each non-skipped field
152                #(#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            /// Filter metadata for the compiler.
158            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
199// ── Attribute parsing ──
200
201struct 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, // #[soma(search)] with no args
221}
222
223/// Parse `#[soma(...)]` on the struct.
224///
225/// Fallible, and strict about names. Every branch below used to be
226/// wrapped in `let _ = attr.parse_nested_meta(..)`, which threw the
227/// result away, and an unrecognised key simply fell through to `Ok(())`.
228/// So `#[soma(serach(low = 1, high = 2))]` compiled cleanly and produced
229/// a filter with no search dimension — the sweep then explored nothing
230/// and reported a best trial, which is a wrong answer with no symptom.
231fn 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: just #[soma(cacheable)] means true
260            } 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                // bare #[soma(deterministic)] (no value) means true
281                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
300/// Parse `#[soma(...)]` on a field. Strict, for the reason above.
301fn 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
401// ── Code generation helpers ──
402
403fn 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    // Categorical from choices
411    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    // Auto-detect for bool
422    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    // Float range
432    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    // Fallback: auto bool already handled, no search for unknown types
470    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        // f64, f32
507        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/// Derive `config_hash` for a `Step`.
516///
517/// Not linked: this is a proc-macro crate and cannot depend on
518/// `somatize-core`, so the path would not resolve.
519///
520/// The mirror of [`macro@SomaFilter`]'s hash half, and it exists because
521/// hand-writing that hash goes wrong quietly. `ReactStep::config_hash`
522/// covered four of its nine fields for as long as it existed; the hash is
523/// part of every journal key a step writes, so two steps differing only
524/// in an uncovered field shared a key and replayed each other's recorded
525/// answers. A field added to a struct that derives this is covered
526/// because it is a field, not because someone remembered.
527///
528/// Fields are hashed as canonical CBOR — never raw serializer output
529/// (a `HashMap`'s iteration order is random) and never a silent empty
530/// fallback (two unserializable configs would collide). A field that
531/// genuinely does not belong in the key says `#[soma(skip_hash)]`, which
532/// is a decision in the source rather than an omission.
533#[proc_macro_derive(SomaStep, attributes(soma))]
534pub fn derive_soma_step(input: TokenStream) -> TokenStream {
535    let input = parse_macro_input!(input as DeriveInput);
536    // A malformed input is a compile error pointing at the offending
537    // token, not a `proc macro panicked` pointing at the derive.
538    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            /// Content-addressable hash of this step's configuration.
594            ///
595            /// Every field that is not `#[soma(skip_hash)]`, so adding one
596            /// cannot silently leave it out of the journal key.
597            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}