From 8c32ad2483d92677476c9c5a14e08b3775ce2743 Mon Sep 17 00:00:00 2001 From: nym21 Date: Fri, 6 Mar 2026 20:33:49 +0100 Subject: [PATCH] traversable_derive: compiles --- .../src/distribution/cohorts/utxo/groups.rs | 234 +++++++++++++----- .../src/distribution/cohorts/utxo/vecs.rs | 30 +-- .../src/distribution/compute/aggregates.rs | 16 +- .../src/distribution/metrics/mod.rs | 29 ++- crates/brk_traversable_derive/src/lib.rs | 109 +++++++- 5 files changed, 324 insertions(+), 94 deletions(-) diff --git a/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs b/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs index 91ed1c563..97b0e99ca 100644 --- a/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs +++ b/crates/brk_computer/src/distribution/cohorts/utxo/groups.rs @@ -1,8 +1,8 @@ use std::path::Path; use brk_cohort::{ - ByAgeRange, ByAmountRange, ByEpoch, ByGreatEqualAmount, ByLowerThanAmount, ByMaxAge, ByMinAge, - ByClass, BySpendableType, CohortContext, Filter, Term, + ByAgeRange, ByAmountRange, ByClass, ByEpoch, ByGreatEqualAmount, ByLowerThanAmount, ByMaxAge, + ByMinAge, BySpendableType, CohortContext, Filter, Term, }; use brk_error::Result; use brk_traversable::Traversable; @@ -13,41 +13,32 @@ use vecdb::{AnyStoredVec, Database, Exit, ReadOnlyClone, ReadableVec, Rw, Storag use crate::{blocks, distribution::DynCohortVecs, indexes, prices}; use crate::distribution::metrics::{ - AllCohortMetrics, BasicCohortMetrics, CohortMetricsBase, - CoreCohortMetrics, ExtendedAdjustedCohortMetrics, ExtendedCohortMetrics, ImportConfig, - MinimalCohortMetrics, SupplyMetrics, + AllCohortMetrics, BasicCohortMetrics, CohortMetricsBase, CoreCohortMetrics, + ExtendedAdjustedCohortMetrics, ExtendedCohortMetrics, ImportConfig, MinimalCohortMetrics, + SupplyMetrics, }; use super::{percentiles::PercentileCache, vecs::UTXOCohortVecs}; -use crate::distribution::state::{CoreRealizedState, MinimalRealizedState, RealizedState, UTXOCohortState}; +use crate::distribution::state::UTXOCohortState; const VERSION: Version = Version::new(0); /// All UTXO cohorts organized by filter type. -/// -/// Each group uses a concrete metrics type matching its required features: -/// - age_range: extended realized + extended cost basis -/// - epoch/class/amount/type: basic metrics with relative -/// - all: extended + adjusted (no rel_to_all) -/// - sth: extended + adjusted -/// - lth: extended -/// - max_age: adjusted -/// - min_age: basic #[derive(Traversable)] pub struct UTXOCohorts { - pub all: UTXOCohortVecs, RealizedState>, - pub sth: UTXOCohortVecs, RealizedState>, - pub lth: UTXOCohortVecs, RealizedState>, - pub age_range: ByAgeRange, RealizedState>>, - pub max_age: ByMaxAge, CoreRealizedState>>, - pub min_age: ByMinAge, CoreRealizedState>>, - pub ge_amount: ByGreatEqualAmount, MinimalRealizedState>>, - pub amount_range: ByAmountRange, MinimalRealizedState>>, - pub lt_amount: ByLowerThanAmount, MinimalRealizedState>>, - pub epoch: ByEpoch, CoreRealizedState>>, - pub class: ByClass, CoreRealizedState>>, - pub type_: BySpendableType, MinimalRealizedState>>, + pub all: UTXOCohortVecs>, + pub sth: UTXOCohortVecs>, + pub lth: UTXOCohortVecs>, + pub age_range: ByAgeRange>>, + pub max_age: ByMaxAge>>, + pub min_age: ByMinAge>>, + pub epoch: ByEpoch>>, + pub class: ByClass>>, + pub ge_amount: ByGreatEqualAmount>>, + pub amount_range: ByAmountRange>>, + pub lt_amount: ByLowerThanAmount>>, + pub type_: BySpendableType>>, #[traversable(skip)] pub(super) percentile_cache: PercentileCache, /// Cached partition_point positions for tick_tock boundary searches. @@ -57,7 +48,6 @@ pub struct UTXOCohorts { pub(super) tick_tock_cached_positions: [usize; 20], } - impl UTXOCohorts { /// ~71 separate cohorts (21 age + 5 epoch + 18 class + 15 amount + 12 type) const SEPARATE_COHORT_CAPACITY: usize = 80; @@ -86,7 +76,7 @@ impl UTXOCohorts { // Helper for separate cohorts with BasicCohortMetrics + full state let basic_separate = - |f: Filter, name: &'static str| -> Result> { + |f: Filter, name: &'static str| -> Result> { let full_name = CohortContext::Utxo.full_name(&f, name); let cfg = ImportConfig { db, @@ -105,7 +95,7 @@ impl UTXOCohorts { let age_range = ByAgeRange::try_new(&basic_separate)?; let core_separate = - |f: Filter, name: &'static str| -> Result> { + |f: Filter, name: &'static str| -> Result> { let full_name = CohortContext::Utxo.full_name(&f, name); let cfg = ImportConfig { db, @@ -126,7 +116,7 @@ impl UTXOCohorts { // Helper for separate cohorts with MinimalCohortMetrics + MinimalRealizedState let minimal_separate = - |f: Filter, name: &'static str| -> Result> { + |f: Filter, name: &'static str| -> Result> { let full_name = CohortContext::Utxo.full_name(&f, name); let cfg = ImportConfig { db, @@ -183,7 +173,7 @@ impl UTXOCohorts { // CoreCohortMetrics without state (no state, for aggregate cohorts) let core_no_state = - |f: Filter, name: &'static str| -> Result> { + |f: Filter, name: &'static str| -> Result> { let full_name = CohortContext::Utxo.full_name(&f, name); let cfg = ImportConfig { db, @@ -206,7 +196,7 @@ impl UTXOCohorts { // MinimalCohortMetrics without state (for aggregate amount cohorts) let minimal_no_state = - |f: Filter, name: &'static str| -> Result> { + |f: Filter, name: &'static str| -> Result> { let full_name = CohortContext::Utxo.full_name(&f, name); let cfg = ImportConfig { db, @@ -246,7 +236,12 @@ impl UTXOCohorts { &mut self, ) -> impl ParallelIterator { let Self { - age_range, epoch, class, amount_range, type_, .. + age_range, + epoch, + class, + amount_range, + type_, + .. } = self; age_range .par_iter_mut() @@ -278,8 +273,15 @@ impl UTXOCohorts { exit: &Exit, ) -> Result<()> { let Self { - all, sth, lth, age_range, max_age, min_age, - ge_amount, amount_range, lt_amount, + all, + sth, + lth, + age_range, + max_age, + min_age, + ge_amount, + amount_range, + lt_amount, .. } = self; @@ -313,10 +315,14 @@ impl UTXOCohorts { }) }), Box::new(|| { - ge_amount.par_iter_mut().chain(lt_amount.par_iter_mut()).try_for_each(|vecs| { - let sources = filter_minimal_sources_from(amr.iter(), Some(&vecs.metrics.filter)); - vecs.metrics.compute_from_sources(si, &sources, exit) - }) + ge_amount + .par_iter_mut() + .chain(lt_amount.par_iter_mut()) + .try_for_each(|vecs| { + let sources = + filter_minimal_sources_from(amr.iter(), Some(&vecs.metrics.filter)); + vecs.metrics.compute_from_sources(si, &sources, exit) + }) }), ]; @@ -338,7 +344,8 @@ impl UTXOCohorts { ) -> Result<()> { // 1. Compute all metrics except net_sentiment (all cohorts via DynCohortVecs) { - let mut all: Vec<&mut dyn DynCohortVecs> = Vec::with_capacity(Self::SEPARATE_COHORT_CAPACITY + 3); + let mut all: Vec<&mut dyn DynCohortVecs> = + Vec::with_capacity(Self::SEPARATE_COHORT_CAPACITY + 3); all.push(&mut self.all); all.push(&mut self.sth); all.push(&mut self.lth); @@ -377,7 +384,10 @@ impl UTXOCohorts { // Note: ge_amount, lt_amount, amount_range are Minimal tier — no net_sentiment. { let Self { - all, sth, lth, age_range, + all, + sth, + lth, + age_range, .. } = self; @@ -387,15 +397,18 @@ impl UTXOCohorts { let tasks: Vec Result<()> + Send + '_>> = vec![ Box::new(|| { let sources = filter_sources_from(ar.iter(), None); - all.metrics.compute_net_sentiment_from_others(si, &sources, exit) + all.metrics + .compute_net_sentiment_from_others(si, &sources, exit) }), Box::new(|| { let sources = filter_sources_from(ar.iter(), Some(sth.metrics.filter())); - sth.metrics.compute_net_sentiment_from_others(si, &sources, exit) + sth.metrics + .compute_net_sentiment_from_others(si, &sources, exit) }), Box::new(|| { let sources = filter_sources_from(ar.iter(), Some(lth.metrics.filter())); - lth.metrics.compute_net_sentiment_from_others(si, &sources, exit) + lth.metrics + .compute_net_sentiment_from_others(si, &sources, exit) }), ]; @@ -454,8 +467,18 @@ impl UTXOCohorts { // Destructure to allow parallel mutable access to independent fields. let Self { - sth, lth, age_range, max_age, min_age, - ge_amount, amount_range, lt_amount, epoch, class, type_, .. + sth, + lth, + age_range, + max_age, + min_age, + ge_amount, + amount_range, + lt_amount, + epoch, + class, + type_, + .. } = self; // All remaining groups run in parallel. Each closure owns an exclusive &mut @@ -465,17 +488,108 @@ impl UTXOCohorts { let ss = &all_supply_sats; let tasks: Vec Result<()> + Send + '_>> = vec![ - Box::new(|| sth.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, vc, vd, ss, exit)), - Box::new(|| lth.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, ss, exit)), - Box::new(|| age_range.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, ss, exit))), - Box::new(|| max_age.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, ss, exit))), - Box::new(|| min_age.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, ss, exit))), - Box::new(|| ge_amount.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit))), - Box::new(|| epoch.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, ss, exit))), - Box::new(|| class.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(blocks, prices, starting_indexes, height_to_market_cap, ss, exit))), - Box::new(|| amount_range.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit))), - Box::new(|| lt_amount.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit))), - Box::new(|| type_.par_iter_mut().try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit))), + Box::new(|| { + sth.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + vc, + vd, + ss, + exit, + ) + }), + Box::new(|| { + lth.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + ss, + exit, + ) + }), + Box::new(|| { + age_range.par_iter_mut().try_for_each(|v| { + v.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + ss, + exit, + ) + }) + }), + Box::new(|| { + max_age.par_iter_mut().try_for_each(|v| { + v.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + ss, + exit, + ) + }) + }), + Box::new(|| { + min_age.par_iter_mut().try_for_each(|v| { + v.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + ss, + exit, + ) + }) + }), + Box::new(|| { + ge_amount + .par_iter_mut() + .try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit)) + }), + Box::new(|| { + epoch.par_iter_mut().try_for_each(|v| { + v.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + ss, + exit, + ) + }) + }), + Box::new(|| { + class.par_iter_mut().try_for_each(|v| { + v.metrics.compute_rest_part2( + blocks, + prices, + starting_indexes, + height_to_market_cap, + ss, + exit, + ) + }) + }), + Box::new(|| { + amount_range + .par_iter_mut() + .try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit)) + }), + Box::new(|| { + lt_amount + .par_iter_mut() + .try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit)) + }), + Box::new(|| { + type_ + .par_iter_mut() + .try_for_each(|v| v.metrics.compute_rest_part2(prices, starting_indexes, exit)) + }), ]; tasks @@ -589,7 +703,7 @@ impl UTXOCohorts { /// Filter source cohorts by an optional filter. /// If filter is None, returns all sources (used for "all" aggregate). fn filter_sources_from<'a, M: CohortMetricsBase + 'a>( - sources: impl Iterator>, + sources: impl Iterator>, filter: Option<&Filter>, ) -> Vec<&'a M> { match filter { @@ -603,7 +717,7 @@ fn filter_sources_from<'a, M: CohortMetricsBase + 'a>( /// Filter MinimalCohortMetrics source cohorts by an optional filter. fn filter_minimal_sources_from<'a>( - sources: impl Iterator>, + sources: impl Iterator>, filter: Option<&Filter>, ) -> Vec<&'a MinimalCohortMetrics> { match filter { diff --git a/crates/brk_computer/src/distribution/cohorts/utxo/vecs.rs b/crates/brk_computer/src/distribution/cohorts/utxo/vecs.rs index 91e822701..10168021e 100644 --- a/crates/brk_computer/src/distribution/cohorts/utxo/vecs.rs +++ b/crates/brk_computer/src/distribution/cohorts/utxo/vecs.rs @@ -7,28 +7,27 @@ use vecdb::{Exit, ReadableVec}; use crate::{blocks, distribution::state::UTXOCohortState, prices}; use crate::distribution::metrics::{ - CohortMetricsBase, CoreCohortMetrics, MinimalCohortMetrics, + CohortMetricsBase, CohortMetricsState, CoreCohortMetrics, MinimalCohortMetrics, }; -use crate::distribution::state::{CoreRealizedState, MinimalRealizedState, RealizedOps, RealizedState}; use super::super::traits::DynCohortVecs; #[derive(Traversable)] -pub struct UTXOCohortVecs { +pub struct UTXOCohortVecs { #[traversable(skip)] state_starting_height: Option, #[traversable(skip)] - pub state: Option>>, + pub state: Option>>, #[traversable(flatten)] - pub metrics: Metrics, + pub metrics: M, } // --- Shared state helpers (identical across all DynCohortVecs impls) --- -impl UTXOCohortVecs { - pub(crate) fn new(state: Option>>, metrics: Metrics) -> Self { +impl UTXOCohortVecs { + pub(crate) fn new(state: Option>>, metrics: M) -> Self { Self { state_starting_height: None, state, @@ -66,17 +65,13 @@ impl UTXOCohortVecs { // --- Blanket impl for CohortMetricsBase types (always use full RealizedState) --- -impl Filtered - for UTXOCohortVecs -{ +impl Filtered for UTXOCohortVecs { fn filter(&self) -> &Filter { self.metrics.filter() } } -impl DynCohortVecs - for UTXOCohortVecs -{ +impl DynCohortVecs for UTXOCohortVecs { fn min_stateful_height_len(&self) -> usize { self.metrics.min_stateful_height_len() } @@ -227,13 +222,13 @@ macro_rules! impl_import_state { // --- MinimalCohortMetrics: uses MinimalRealizedState --- -impl Filtered for UTXOCohortVecs { +impl Filtered for UTXOCohortVecs { fn filter(&self) -> &Filter { &self.metrics.filter } } -impl DynCohortVecs for UTXOCohortVecs { +impl DynCohortVecs for UTXOCohortVecs { fn min_stateful_height_len(&self) -> usize { self.metrics.min_stateful_height_len() } @@ -311,13 +306,13 @@ impl DynCohortVecs for UTXOCohortVecs { +impl Filtered for UTXOCohortVecs { fn filter(&self) -> &Filter { &self.metrics.filter } } -impl DynCohortVecs for UTXOCohortVecs { +impl DynCohortVecs for UTXOCohortVecs { fn min_stateful_height_len(&self) -> usize { self.metrics.min_stateful_height_len() } @@ -392,4 +387,3 @@ impl DynCohortVecs for UTXOCohortVecs { self.reset_iteration_impl(); } } - diff --git a/crates/brk_computer/src/distribution/compute/aggregates.rs b/crates/brk_computer/src/distribution/compute/aggregates.rs index d32aed210..506969f59 100644 --- a/crates/brk_computer/src/distribution/compute/aggregates.rs +++ b/crates/brk_computer/src/distribution/compute/aggregates.rs @@ -20,8 +20,12 @@ pub(crate) fn compute_overlapping( ) -> Result<()> { info!("Computing overlapping cohorts..."); - utxo_cohorts.compute_overlapping_vecs(starting_indexes, exit)?; - address_cohorts.compute_overlapping_vecs(starting_indexes, exit)?; + let (r1, r2) = rayon::join( + || utxo_cohorts.compute_overlapping_vecs(starting_indexes, exit), + || address_cohorts.compute_overlapping_vecs(starting_indexes, exit), + ); + r1?; + r2?; Ok(()) } @@ -39,8 +43,12 @@ pub(crate) fn compute_rest_part1( ) -> Result<()> { info!("Computing rest part 1..."); - utxo_cohorts.compute_rest_part1(blocks, prices, starting_indexes, exit)?; - address_cohorts.compute_rest_part1(blocks, prices, starting_indexes, exit)?; + let (r1, r2) = rayon::join( + || utxo_cohorts.compute_rest_part1(blocks, prices, starting_indexes, exit), + || address_cohorts.compute_rest_part1(blocks, prices, starting_indexes, exit), + ); + r1?; + r2?; Ok(()) } diff --git a/crates/brk_computer/src/distribution/metrics/mod.rs b/crates/brk_computer/src/distribution/metrics/mod.rs index bc3e75c00..627a31b09 100644 --- a/crates/brk_computer/src/distribution/metrics/mod.rs +++ b/crates/brk_computer/src/distribution/metrics/mod.rs @@ -164,11 +164,34 @@ pub use unrealized::*; use brk_cohort::Filter; use brk_error::Result; use brk_types::{Cents, Height, Indexes, Version}; -use vecdb::{AnyStoredVec, Exit}; +use vecdb::{AnyStoredVec, Exit, StorageMode}; -use crate::{blocks, distribution::state::{CohortState, RealizedState}, prices}; +use crate::{blocks, distribution::state::{CohortState, CoreRealizedState, MinimalRealizedState, RealizedOps, RealizedState}, prices}; -pub trait CohortMetricsBase: Send + Sync { +pub trait CohortMetricsState { + type Realized: RealizedOps; +} + +impl CohortMetricsState for MinimalCohortMetrics { + type Realized = MinimalRealizedState; +} +impl CohortMetricsState for CoreCohortMetrics { + type Realized = CoreRealizedState; +} +impl CohortMetricsState for BasicCohortMetrics { + type Realized = RealizedState; +} +impl CohortMetricsState for ExtendedCohortMetrics { + type Realized = RealizedState; +} +impl CohortMetricsState for ExtendedAdjustedCohortMetrics { + type Realized = RealizedState; +} +impl CohortMetricsState for AllCohortMetrics { + type Realized = RealizedState; +} + +pub trait CohortMetricsBase: CohortMetricsState + Send + Sync { fn filter(&self) -> &Filter; fn supply(&self) -> &SupplyMetrics; fn supply_mut(&mut self) -> &mut SupplyMetrics; diff --git a/crates/brk_traversable_derive/src/lib.rs b/crates/brk_traversable_derive/src/lib.rs index 0466847e3..583d9cdde 100644 --- a/crates/brk_traversable_derive/src/lib.rs +++ b/crates/brk_traversable_derive/src/lib.rs @@ -600,6 +600,11 @@ fn type_contains_ident(ty: &Type, ident: &syn::Ident) -> bool { /// - Types with `M: StorageMode` → maps `Self` → `Self`. /// - Types with other generic type params (no M) → propagates `ReadOnlyClone` through each param. /// - Types with no generic type params → nothing generated (they should `#[derive(Clone)]`). +/// +/// Container params (mapped through ReadOnlyClone) are identified by: +/// - Unbounded type params (no inline or where-clause bounds), OR +/// - Bounded params that appear as a bare field type in a non-skipped field +/// (e.g. `metrics: M` where M is the param itself). fn gen_read_only_clone(input: &DeriveInput) -> proc_macro2::TokenStream { let generics = &input.generics; let name = &input.ident; @@ -627,8 +632,6 @@ fn gen_read_only_clone(input: &DeriveInput) -> proc_macro2::TokenStream { } // Determine which type params have bounds (inline or via where clause). - // Bounded params are "leaf" params — kept as-is in the ReadOnly target. - // Unbounded params are "container" params — mapped through ReadOnlyClone. let where_bounded: Vec<&syn::Ident> = if let Some(where_clause) = &generics.where_clause { where_clause .predicates @@ -651,9 +654,17 @@ fn gen_read_only_clone(input: &DeriveInput) -> proc_macro2::TokenStream { Vec::new() }; + // Find params that appear as bare (direct) field types in non-skipped fields. + let bare_field_params = find_bare_field_params(data, &type_params); + + // Container params: unbounded OR bare-field params. let container_params: Vec<&syn::Ident> = type_params .iter() - .filter(|tp| tp.bounds.is_empty() && !where_bounded.contains(&&tp.ident)) + .filter(|tp| { + let is_unbounded = tp.bounds.is_empty() && !where_bounded.contains(&&tp.ident); + let is_bare = bare_field_params.contains(&&tp.ident); + is_unbounded || is_bare + }) .map(|tp| &tp.ident) .collect(); @@ -662,7 +673,37 @@ fn gen_read_only_clone(input: &DeriveInput) -> proc_macro2::TokenStream { return quote! {}; } - gen_read_only_clone_for_generics(name, generics, data, &container_params) + gen_read_only_clone_for_generics(name, generics, data, &type_params, &container_params) +} + +/// Find type params that appear as bare (direct) field types in non-skipped fields. +/// E.g. `metrics: M` where M is a type param → M is a bare field param. +fn find_bare_field_params<'a>( + data: &syn::DataStruct, + type_params: &[&'a syn::TypeParam], +) -> Vec<&'a syn::Ident> { + let fields: &syn::punctuated::Punctuated = match &data.fields { + Fields::Named(named) => &named.named, + Fields::Unnamed(unnamed) => &unnamed.unnamed, + Fields::Unit => return Vec::new(), + }; + + let mut bare = Vec::new(); + for field in fields { + if is_field_skipped(field) { + continue; + } + if let Type::Path(type_path) = &field.ty + && type_path.path.segments.len() == 1 + && let Some(seg) = type_path.path.segments.first() + && seg.arguments.is_empty() + { + if let Some(tp) = type_params.iter().find(|tp| tp.ident == seg.ident) { + bare.push(&tp.ident); + } + } + } + bare } /// Generate `ReadOnlyClone` for types with `M: StorageMode`. @@ -798,14 +839,18 @@ fn is_field_skipped(field: &syn::Field) -> bool { /// Generate `ReadOnlyClone` for types with generic type params but no `M: StorageMode`. /// -/// `container_params` are unbounded type params that get `ReadOnlyClone` bounds and are +/// `container_params` are type params that get `ReadOnlyClone` bounds and are /// mapped to `T::ReadOnly` in the target type. -/// Bounded type params (leaf params) are kept as-is — they don't change across storage modes. +/// Leaf type params are kept as-is — they don't change across storage modes. /// Fields containing container params use `.read_only_clone()`, others use `.clone()`. +/// +/// For bounded container params, the original bounds are preserved and propagated +/// to the ReadOnly version via where clause (e.g. `M::ReadOnly: CohortMetricsState`). fn gen_read_only_clone_for_generics( name: &syn::Ident, generics: &syn::Generics, data: &syn::DataStruct, + type_params: &[&syn::TypeParam], container_params: &[&syn::Ident], ) -> proc_macro2::TokenStream { // Check if any non-skipped field references a container param (otherwise skip). @@ -832,6 +877,7 @@ fn gen_read_only_clone_for_generics( let is_container = |ident: &syn::Ident| container_params.iter().any(|cp| *cp == ident); // Impl generics: add ReadOnlyClone bound to container params, keep bounds for leaf params. + // For bounded container params, preserve original bounds alongside ReadOnlyClone. let impl_params: Vec = generics .params .iter() @@ -840,7 +886,11 @@ fn gen_read_only_clone_for_generics( let ident = &tp.ident; let bounds = &tp.bounds; if is_container(ident) { - quote! { #ident: vecdb::ReadOnlyClone } + if bounds.is_empty() { + quote! { #ident: vecdb::ReadOnlyClone } + } else { + quote! { #ident: #bounds + vecdb::ReadOnlyClone } + } } else if bounds.is_empty() { quote! { #ident } } else { @@ -900,7 +950,48 @@ fn gen_read_only_clone_for_generics( }) .collect(); - let where_clause = &generics.where_clause; + // Build where clause: propagate bounds from bounded container params to their ReadOnly. + // E.g. `M: Trait` → add `::ReadOnly: Trait`. + let mut extra_where: Vec = Vec::new(); + + // Propagate inline bounds. + for tp in type_params { + if is_container(&tp.ident) && !tp.bounds.is_empty() { + let ident = &tp.ident; + let bounds = &tp.bounds; + extra_where.push(quote! { + <#ident as vecdb::ReadOnlyClone>::ReadOnly: #bounds + }); + } + } + + // Propagate where-clause bounds for container params. + if let Some(wc) = &generics.where_clause { + for pred in &wc.predicates { + if let syn::WherePredicate::Type(pt) = pred + && let Type::Path(tp) = &pt.bounded_ty + && let Some(seg) = tp.path.segments.first() + && container_params.iter().any(|cp| **cp == seg.ident) + { + let ident = &seg.ident; + let bounds = &pt.bounds; + extra_where.push(quote! { + <#ident as vecdb::ReadOnlyClone>::ReadOnly: #bounds + }); + } + } + } + + let original_predicates = generics + .where_clause + .as_ref() + .map(|w| &w.predicates); + + let combined_where = if extra_where.is_empty() && original_predicates.is_none() { + quote! {} + } else { + quote! { where #(#extra_where,)* #original_predicates } + }; // Field-level: if field type contains any container param → read_only_clone, else → clone. let field_contains_container_param = @@ -954,7 +1045,7 @@ fn gen_read_only_clone_for_generics( }; quote! { - impl<#(#impl_params),*> vecdb::ReadOnlyClone for #name<#(#self_ty_args),*> #where_clause { + impl<#(#impl_params),*> vecdb::ReadOnlyClone for #name<#(#self_ty_args),*> #combined_where { type ReadOnly = #name<#(#ro_ty_args),*>; fn read_only_clone(&self) -> Self::ReadOnly {